From 78d32c951ad08738fe8ae1a353ac3cecdfc89cca Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Tue, 10 Nov 2026 03:23:04 +0200 Subject: [PATCH 1/2] Host the H5P build page and the library bundles at /h5p/ The build page and the bundles move here from embed-my.github.io's /build and /bundles/, as /h5p/build/ and /h5p/bundles/, the page in this site's own layout and palette. Eleventy writes the pages, then Vite bundles the page's script with the player, its runtime and the library pack into /h5p/assets/, so the player's Service Worker is scoped to /h5p/assets/h5p/ and no other page is under it. The bundles are written at build, as embed-my writes them. The theme switch moves into an include the docs layout shares; the docs' HTML is unchanged byte for byte. base.njk takes an optional csp. Closes #7 --- .github/dependabot.yml | 3 +- .github/workflows/build.yml | 9 + .gitignore | 4 + README.md | 52 ++- e2e/build.spec.ts | 174 ++++++++ e2e/bundles.spec.ts | 88 ++++ package.json | 18 +- playwright.config.ts | 24 ++ pnpm-lock.yaml | 711 +++++++++++++++++++++++++++++++++ src/_includes/base.njk | 3 + src/_includes/pdfjs-viewer.njk | 7 +- src/_includes/theme-switch.njk | 7 + src/assets/site.css | 67 ++++ src/h5p/build/build-page.ts | 226 +++++++++++ src/h5p/build/build-source.ts | 132 ++++++ src/h5p/build/index.njk | 97 +++++ tsconfig.json | 31 ++ vite.config.ts | 144 +++++++ 18 files changed, 1783 insertions(+), 14 deletions(-) create mode 100644 e2e/build.spec.ts create mode 100644 e2e/bundles.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/_includes/theme-switch.njk create mode 100644 src/h5p/build/build-page.ts create mode 100644 src/h5p/build/build-source.ts create mode 100644 src/h5p/build/index.njk create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 03fe1d0..3bf8ad5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,7 @@ version: 2 updates: - # The docs run the pdfjs-viewer version they describe, so new releases arrive as their own PR. + # The docs run the pdfjs-viewer version they describe, and /h5p/ serves the H5P packages it + # builds with, so their releases arrive as their own PRs. - package-ecosystem: npm directory: / schedule: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ee658f5..e4d36ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,3 +24,12 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build + # The H5P build page and the bundles in a real browser, against the built site. + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm e2e + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-traces + path: test-results + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 24c9945..249780b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ _site/ .DS_Store + +# Playwright +test-results/ +playwright-report/ diff --git a/README.md b/README.md index 2168d7d..7f03167 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,32 @@ # missing-elements.github.io -Docs for the [missing-elements](https://github.com/missing-elements) web components, published at . +Docs and tools for the [missing-elements](https://github.com/missing-elements) web components, published at . | Path | Source | | --- | --- | | `/` | [src/index.njk](src/index.njk), project list in [src/_data/site.js](src/_data/site.js) | | `/pdfjs-viewer/` | [src/pdfjs-viewer/index.md](src/pdfjs-viewer/index.md) | +| `/h5p/build/` | The H5P build page, [src/h5p/build/](src/h5p/build/): see [The H5P build page](#the-h5p-build-page) | +| `/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) | -Built with [Eleventy](https://www.11ty.dev/). The pages ship no framework: the only client-side JavaScript is the theme toggle, code-block buttons and the live preview. +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. ## Develop +Needs Node 22.12 or later. + ```bash pnpm install -pnpm dev # http://localhost:8080 -pnpm build # writes _site/ +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 + # per machine: pnpm exec playwright install chromium ``` -Pushes to `main` deploy through [.github/workflows/pages.yml](.github/workflows/pages.yml). Pull requests get a build check from [.github/workflows/build.yml](.github/workflows/build.yml). +`pnpm dev` serves the pages as Eleventy writes them; the build page's script is there only after a `pnpm build`. + +Pushes to `main` deploy through [.github/workflows/pages.yml](.github/workflows/pages.yml). Pull requests get a build check from [.github/workflows/build.yml](.github/workflows/build.yml), which runs the browser tests too. ## Editing the pdfjs-viewer docs @@ -39,3 +48,36 @@ Add `try` after the language of a code fence to give it a **▶ Try** button tha - **JavaScript snippets** run exactly as written. `document.querySelector('pdfjs-viewer-element')` returns the preview element. - Every Try button comes with a **Reset** button that puts the preview back to its defaults. - **`try=fresh`** replaces the preview with a new element first. Use it for settings that only apply when the viewer is built, such as `setViewerOptions` and the "before connect" attributes. + +## The H5P build page + +`/h5p/build/` is for an AI chat that wrote a source with the [h5p-author skill](https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author) but cannot build it. ChatGPT's sandbox reaches npm only through OpenAI's own mirror, which was reported refusing every install from July 2026, so `npx @missing-elements/h5p-author` does not run there, and the skill hands over the source and this page's address instead. The page builds with `@missing-elements/h5p-author/browser`, the same build as the command's, and lists every problem at once by its path, ready to copy back into the chat. Its libraries are `@missing-elements/h5p-libraries/install`'s pack, one part per library, so a build fetches only the parts its package needs and carries the editor libraries an H5P site's upload check asks for. The package then plays in the player on the page, and downloads from it. + +Nothing leaves the browser. A link can carry the source in its fragment, which no server sees: `/h5p/build/#source=`, the bytes being the source's JSON as UTF-8, or that JSON compressed with raw deflate (Node's `zlib.deflateRawSync`, Python's `zlib.compressobj(wbits=-15)`) to keep the link short. A linked source is built at once, unless it names files of its own, which only the person can choose. A source that names a picture by web address gets it only where that host allows CORS. + +| File | What it holds | +| --- | --- | +| `src/h5p/build/index.njk` | The page's markup, in the site's layout, and its Content-Security-Policy: GitHub Pages sends no headers, so a `` tag is the only policy it gets | +| `src/h5p/build/build-page.ts` | The page's wiring: the build, the player, the download, a link's source | +| `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 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/` | +| `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 for the chat; `/h5p/bundles/` serves every bundle and part, and its tool and a bundle build a quiz in an empty folder | + +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. + +The page and the bundles were embed-my.github.io's `/build` and `/bundles/` until 2026-11-10. `/build` there now redirects here with its fragment, so the links chats wrote before still build. + +## The bundles + +`/h5p/bundles/` is for a chat that runs Node and can fetch a file but reaches no npm registry, as ChatGPT's sandbox can: its download tool takes a file the browsing tool has opened. There the h5p-author skill fetches two files, the build tool and the bundle of the source's content type, unzips the bundle and builds with `node h5p-author.mjs build source.json course.h5p --libraries parts`. The chat then sees every problem itself, fixes its source and hands over the package; the build page stays for a chat that can fetch nothing. + +| Path | What it holds | +| --- | --- | +| `/h5p/bundles/.zip` | One per content type in the pack, `H5P.QuestionSet.zip`: `parts/`, the pack's whole index with the parts a build of that type can read, and `libraries.txt`. Written by `h5p-author bundle --all` | +| `/h5p/bundles/parts/.h5p` | Every part of the pack under its own name, and `parts/index.json`. No bundle holds an addon: a source whose text calls for one, H5P.MathDisplay for math, stops the build, which names the part to fetch from here | +| `/h5p/bundles/h5p-author.mjs` | The author package's command line as one file, everything it imports bundled in, so it runs with nothing installed. It needs `--libraries`, and has no `--version`: the npm package's own files are not with it | +| `/h5p/bundles/index.json` | The author package's and the pack's versions, and each bundle's file, size and number of parts | +| `/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. diff --git a/e2e/build.spec.ts b/e2e/build.spec.ts new file mode 100644 index 0000000..9fa2d40 --- /dev/null +++ b/e2e/build.spec.ts @@ -0,0 +1,174 @@ +import { deflateRawSync } from 'node:zlib' +import { expect, test, type Page } from '@playwright/test' + +/* + * The build page, /h5p/build/, in a real browser against the built site: a source pasted, opened + * from a link or naming a file of its own becomes a package that plays on the page and downloads + * from it, and a source with problems gets them listed, all at once, for the chat that wrote it. + * Every library comes from this origin, and any Content-Security-Policy violation or page error + * fails the test. + */ + +const RIVERS = { + type: 'H5P.Blanks', + title: 'Rivers', + language: 'en', + license: 'CC BY', + authors: [{ name: 'Ann Author', role: 'Author' }], + params: { questions: ['The *Danube* flows into the Black Sea.'] } +} + +/** A 2×2 PNG, red. */ +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP4z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg==', 'base64') + +/** Collects console errors, CSP reports, page errors and requests that leave the origin. */ +function watch(page: Page, baseURL: string | undefined): { problems: string[], elsewhere: string[] } { + const problems: string[] = [] + const elsewhere: string[] = [] + page.on('console', (message) => { + const text = message.text() + if (message.type() === 'error' || /Content.Security.Policy/i.test(text)) problems.push(text) + }) + page.on('pageerror', (error) => problems.push(error.message)) + page.on('request', (request) => { + const url = request.url() + if (/^https?:/.test(url) && !url.startsWith(baseURL ?? '')) elsewhere.push(url) + }) + return { problems, elsewhere } +} + +const playerState = (page: Page) => page.locator('h5p-player').evaluate((element: HTMLElement & { state?: string }) => element.state) + +async function expectPlays(page: Page): Promise { + await expect(page.locator('#status')).toHaveAttribute('data-state', 'ready', { timeout: 40_000 }) + await expect.poll(() => playerState(page)).toBe('ready') + await expect(page.frameLocator('h5p-player iframe').first().locator('.h5p-content, .h5p-container').first()).toBeVisible() +} + +test('a pasted source builds, plays and downloads', async ({ page, baseURL }) => { + const { problems, elsewhere } = watch(page, baseURL) + await page.goto('/h5p/build/') + await expect(page.locator('meta[http-equiv="Content-Security-Policy"]')).toHaveCount(1) + // The site's own layout: its theme switch, which site.js shows once it runs under the page's policy. + await expect(page.locator('[data-theme-toggle]')).toBeVisible() + await expect(page.locator('#files-step')).toBeHidden() + // A chat hands the source in a code fence; the page takes what is inside it. + await page.locator('#source').fill('```json\n' + JSON.stringify(RIVERS, null, 2) + '\n```') + await page.locator('#build').click() + await expectPlays(page) + await expect(page.locator('#status')).toContainText('Built “Rivers”: H5P.Blanks, ') + await expect(page.locator('#problems-box')).toBeHidden() + await expect(page.frameLocator('h5p-player iframe').first().locator('.h5p-blanks')).toContainText('flows into the Black Sea') + // The player's worker is scoped to its own folder of assets: no other page of the site is under it. + const scopes = await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).map(({ scope }) => new URL(scope).pathname)) + expect(scopes).toEqual(['/h5p/assets/h5p/']) + + const [download] = await Promise.all([page.waitForEvent('download'), page.locator('#download').click()]) + expect(download.suggestedFilename()).toBe('rivers.h5p') + const stream = await download.createReadStream() + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(chunk as Buffer) + const zip = Buffer.concat(chunks) + expect(zip.subarray(0, 4).toString('latin1'), 'a zip').toBe('PK\x03\x04') + // The libraries an H5P site's upload check asks for are in it, as the command's build writes them. + expect(zip.includes(Buffer.from('H5PEditor.'))).toBe(true) + + expect(elsewhere, 'nothing was fetched from another site').toEqual([]) + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('a source with problems gets them all listed, ready for the chat', async ({ page, context, baseURL }) => { + const { problems } = watch(page, baseURL) + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.goto('/h5p/build/') + const { title: _title, ...untitled } = RIVERS + await page.locator('#source').fill(JSON.stringify({ ...untitled, colour: 'blue' })) + await page.locator('#build').click() + await expect(page.locator('#status')).toHaveAttribute('data-state', 'error') + const listed = page.locator('#problems li') + // Each problem by its path, every one at once. + await expect(listed.locator('code', { hasText: /^colour$/ })).toHaveCount(1) + await expect(listed.locator('code', { hasText: /^title$/ })).toHaveCount(1) + await expect(page.locator('#play-step')).toBeHidden() + + await page.locator('#copy-problems').click() + await expect(page.locator('#copy-result')).toHaveText('Copied.') + const copied = await page.evaluate(() => navigator.clipboard.readText()) + expect(copied).toMatch(/^The build page found \d+ problems in the source\. Fix them all and write the whole source again:\n- /) + expect(copied).toContain('- colour: ') + + // Text that is not JSON is a problem of its own, said where it is. + await page.locator('#source').fill('{ "type": "H5P.Blanks", ') + await page.locator('#build').click() + await expect(listed).toHaveCount(1) + await expect(listed.first()).toContainText('is not valid JSON') + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('a link carrying the source builds it at once, deflated or not', async ({ page, baseURL }) => { + const { problems, elsewhere } = watch(page, baseURL) + const deflated = deflateRawSync(Buffer.from(JSON.stringify(RIVERS))).toString('base64url') + await page.goto(`/h5p/build/#source=${deflated}`) + await expectPlays(page) + await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS)) + + // Plain base64url, with umlauts and ß: the bytes are UTF-8, and the file name keeps the letters. + const plain = Buffer.from(JSON.stringify({ ...RIVERS, language: 'de', title: 'Größte Flüsse' })).toString('base64url') + await page.goto(`/h5p/build/#source=${plain}`) + await expect(page.locator('#status')).toContainText('Built “Größte Flüsse”') + await expectPlays(page) + await expect(page.locator('#download')).toHaveAttribute('download', 'größte-flüsse.h5p') + + // A title in another script keeps its letters too, rather than becoming activity.h5p. + await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify({ ...RIVERS, language: 'uk', title: 'Річки України' })).toString('base64url')}`) + await expectPlays(page) + await expect(page.locator('#download')).toHaveAttribute('download', 'річки-україни.h5p') + + expect(elsewhere, 'nothing was fetched from another site').toEqual([]) + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('a Question Set shows its cover title as written, tags and entities nowhere', async ({ page, baseURL }) => { + const { problems } = watch(page, baseURL) + // h5p-author 0.5.0 wrapped this plain field in
, which the set draws as text (h5p-offline-player#164). + const title = "Germany's Surrender: die größten Ereignisse" + const quiz = { + type: 'H5P.QuestionSet', title: 'Surrender', language: 'en', license: 'CC BY', authors: [{ name: 'Ann Author', role: 'Author' }], + params: { + introPage: { showIntroPage: true, title, introduction: 'Five questions.' }, + questions: [{ library: 'H5P.TrueFalse', params: { question: 'The war in Europe ended in 1945.', correct: 'true' } }] + } + } + await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify(quiz)).toString('base64url')}`) + await expectPlays(page) + await expect(page.frameLocator('h5p-player iframe').first().locator('h2').first()).toHaveText(title) + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('a source that names a file waits for it, then builds with it', async ({ page, baseURL }) => { + const { problems } = watch(page, baseURL) + const delta = { + type: 'H5P.ImageHotspots', + title: 'Delta', + language: 'en', + license: 'CC BY', + authors: [{ name: 'Ann Author', role: 'Author' }], + params: { + image: { path: 'pictures/delta.png' }, + backgroundImageAltText: 'A river delta seen from above', + hotspots: [{ position: { x: 30, y: 40 }, header: 'Main channel', content: [{ library: 'H5P.Text', params: { text: 'Most of the water flows here.' } }] }] + } + } + await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify(delta)).toString('base64url')}`) + await expect(page.locator('#files-step')).toBeVisible() + await expect(page.locator('#build-number')).toHaveText('3') + await expect(page.locator('#file-list li')).toHaveAttribute('data-chosen', 'false') + await expect(page.locator('#status')).toHaveText('The source names files: choose them, then Build.') + + await page.locator('#files').setInputFiles({ name: 'delta.png', mimeType: 'image/png', buffer: PNG }) + await expect(page.locator('#file-list li')).toHaveAttribute('data-chosen', 'true') + await page.locator('#build').click() + await expectPlays(page) + await expect(page.locator('#status')).toContainText('1 media file') + expect(problems, problems.join('\n')).toEqual([]) +}) diff --git a/e2e/bundles.spec.ts b/e2e/bundles.spec.ts new file mode 100644 index 0000000..48b63d3 --- /dev/null +++ b/e2e/bundles.spec.ts @@ -0,0 +1,88 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { expect, test, type APIRequestContext } from '@playwright/test' + +/* + * /h5p/bundles/ as a chat's sandbox uses it, following the h5p-author skill's steps: the command + * line and one content type's bundle fetched into an empty folder outside this project, so nothing + * installed here is found, unzipped and built with Node alone; and an addon the text calls for, + * fetched as its own part once the build has named it. + */ + +const exec = promisify(execFile) + +type Listing = { + author: string, pack: string, tool: string, parts: string, licences: string, + bundles: Array<{ type: string, version: string, file: string, size: number, parts: number }> +} + +const META = { language: 'en', license: 'CC BY', authors: [{ name: 'Ann Author', role: 'Author' }] } + +/** A file of the site saved into the folder, as a chat's download tool saves it. */ +async function fetchTo(request: APIRequestContext, path: string, file: string): Promise { + const response = await request.get(path) + expect(response.ok(), path).toBe(true) + await writeFile(file, await response.body()) +} + +/** The command line run in the folder, with the Node running the tests. */ +const tool = (cwd: string, ...args: string[]) => exec(process.execPath, ['h5p-author.mjs', ...args], { cwd }) + +test('lists a bundle per content type, and serves each, every part on its own, the tool and the licences', async ({ request }) => { + const listing = await (await request.get('/h5p/bundles/index.json')).json() as Listing + expect(listing).toMatchObject({ author: expect.stringMatching(/^\d+\.\d+\.\d+$/), pack: expect.stringMatching(/^\d+\.\d+\.\d+$/), tool: 'h5p-author.mjs' }) + expect(listing.bundles.length).toBeGreaterThan(40) + expect(listing.bundles.map(({ type }) => type)).toEqual(expect.arrayContaining(['H5P.QuestionSet', 'H5P.Blanks', 'H5P.CoursePresentation'])) + for (const { file, size } of listing.bundles) { + const response = await request.head(`/h5p/bundles/${file}`) + expect(response.ok(), file).toBe(true) + expect(Number(response.headers()['content-length']), file).toBe(size) + } + + const index = await (await request.get(`/h5p/bundles/${listing.parts}index.json`)).json() as { libraries: Array<{ folder: string }> } + expect(index.libraries.length).toBeGreaterThan(100) + for (const { folder } of index.libraries) expect((await request.head(`/h5p/bundles/${listing.parts}${folder}.h5p`)).ok(), folder).toBe(true) + expect(await (await request.get(`/h5p/bundles/${listing.licences}`)).text()).toContain('H5P.QuestionSet-') + expect(await (await request.get(`/h5p/bundles/${listing.tool}`)).text()).toMatch(/^#!\/usr\/bin\/env node\n/) +}) + +test('builds a quiz in an empty folder from the tool and its bundle, and takes an addon as its own part', async ({ request }) => { + const dir = await mkdtemp(join(tmpdir(), 'embed-my-bundles-')) + try { + await fetchTo(request, '/h5p/bundles/h5p-author.mjs', join(dir, 'h5p-author.mjs')) + await fetchTo(request, '/h5p/bundles/H5P.QuestionSet.zip', join(dir, 'H5P.QuestionSet.zip')) + await exec('unzip', ['-q', 'H5P.QuestionSet.zip'], { cwd: dir }) + + const quiz = { + ...META, + type: 'H5P.QuestionSet', + title: 'Rivers', + params: { + questions: [ + { library: 'H5P.MultiChoice', params: { question: 'Which river is the longest in Europe?', answers: [{ text: 'Volga', correct: true }, { text: 'Danube', correct: false }] } }, + { library: 'H5P.TrueFalse', params: { question: 'The Rhine flows into the North Sea.', correct: 'true' } } + ] + } + } + await writeFile(join(dir, 'quiz.json'), JSON.stringify(quiz)) + const { stdout } = await tool(dir, 'build', 'quiz.json', 'quiz.h5p', '--libraries', 'parts') + expect(stdout).toContain('Built ') + const manifest = JSON.parse((await exec('unzip', ['-p', 'quiz.h5p', 'h5p.json'], { cwd: dir })).stdout) as { mainLibrary: string } + expect(manifest.mainLibrary).toBe('H5P.QuestionSet') + + // Math in the text calls for H5P.MathDisplay, which no bundle holds: the build names its part. + const squares = { ...quiz, title: 'Squares', params: { questions: [{ library: 'H5P.Blanks', params: { questions: ['The square of 3, $$3^2$$, is *9*.'] } }] } } + await writeFile(join(dir, 'squares.json'), JSON.stringify(squares)) + const failed = await tool(dir, 'build', 'squares.json', 'squares.h5p', '--libraries', 'parts').then(() => null, (error: { code?: number, stderr?: string }) => error) + expect(failed?.code).toBe(1) + const part = /has no (\S+\.h5p)/.exec(failed?.stderr ?? '')?.[1] + expect(part, failed?.stderr).toMatch(/^H5P\.MathDisplay-\d+\.\d+\.h5p$/) + await fetchTo(request, `/h5p/bundles/parts/${part}`, join(dir, 'parts', part!)) + expect((await tool(dir, 'build', 'squares.json', 'squares.h5p', '--libraries', 'parts')).stdout).toContain('Built ') + } finally { + await rm(dir, { recursive: true, force: true }) + } +}) diff --git a/package.json b/package.json index 62b8249..71d7c1c 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,29 @@ "private": true, "type": "module", "packageManager": "pnpm@10.24.0", + "engines": { + "node": ">=22.12" + }, "scripts": { "dev": "eleventy --serve", - "build": "eleventy" + "build": "tsc && eleventy && vite build", + "typecheck": "tsc", + "preview": "vite preview", + "e2e": "playwright test" }, "devDependencies": { "@11ty/eleventy": "3.1.7", - "@11ty/eleventy-plugin-syntaxhighlight": "5.0.2" + "@11ty/eleventy-plugin-syntaxhighlight": "5.0.2", + "@playwright/test": "1.64.1", + "@types/node": "26.6.6", + "typescript": "7.0.2", + "vite": "8.3.5" }, "dependencies": { + "@missing-elements/h5p-author": "0.6.0", + "@missing-elements/h5p-libraries": "1.1.0", + "@missing-elements/h5p-offline-player": "1.5.1", + "@missing-elements/h5p-runtime": "1.0.1", "@missing-elements/pdfjs-viewer": "4.5.0" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..eca69b6 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test' + +/* + * The browser tests run against the built site, `_site/`, served the way GitHub Pages serves it: + * static files, a folder as its index.html. Run `pnpm build` first; `pnpm e2e` then starts the + * preview server. + */ +export default defineConfig({ + testDir: 'e2e', + timeout: 60_000, + expect: { timeout: 20_000 }, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + browserName: 'chromium', + trace: 'retain-on-failure' + }, + webServer: { + command: 'pnpm exec vite preview --host 127.0.0.1 --port 4173 --strictPort', + url: 'http://127.0.0.1:4173/h5p/bundles/index.json', + reuseExistingServer: !process.env.CI + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaa287f..72bab58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,18 @@ importers: .: dependencies: + '@missing-elements/h5p-author': + specifier: 0.6.0 + version: 0.6.0(@missing-elements/h5p-runtime@1.0.1) + '@missing-elements/h5p-libraries': + specifier: 1.1.0 + version: 1.1.0 + '@missing-elements/h5p-offline-player': + specifier: 1.5.1 + version: 1.5.1(@missing-elements/h5p-runtime@1.0.1) + '@missing-elements/h5p-runtime': + specifier: 1.0.1 + version: 1.0.1 '@missing-elements/pdfjs-viewer': specifier: 4.5.0 version: 4.5.0 @@ -18,6 +30,18 @@ importers: '@11ty/eleventy-plugin-syntaxhighlight': specifier: 5.0.2 version: 5.0.2 + '@playwright/test': + specifier: 1.64.1 + version: 1.64.1 + '@types/node': + specifier: 26.6.6 + version: 26.6.6 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.3.5 + version: 8.3.5(@types/node@26.6.6) packages: @@ -77,6 +101,33 @@ packages: resolution: {integrity: sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==} engines: {node: '>=18'} + '@missing-elements/h5p-author@0.6.0': + resolution: {integrity: sha512-sJRIGjuOs8Aj9RtaivBz39rqRJLVC4L6vqBffGRGV+V8Zj4ESrLjLnlE1hlLnY8AfccPOZCOzrnO5EG6a4CBiQ==} + engines: {node: '>=22'} + hasBin: true + + '@missing-elements/h5p-libraries@1.1.0': + resolution: {integrity: sha512-gH4Qpvms3hDKzxSjAHg0UXQqUlkNPravGoMDmJH04XqVuNo41FG3JuxsuTzUV6k9LO3sD72XJ34K0VwThKGcTg==} + engines: {node: '>=20'} + + '@missing-elements/h5p-normalize@1.3.0': + resolution: {integrity: sha512-QuJsxpsSp/aG6MIBRGZk9TDyjLG+k7DdNo8pgdiGT2S3mC8C0l8Vj6SzvyIpDx5egiQSI8F+tRkRuFiVgUP0bg==} + engines: {node: '>=22'} + hasBin: true + + '@missing-elements/h5p-offline-player@1.5.1': + resolution: {integrity: sha512-vxIqhwtd4aNb/doLea4WrnGmL0zGLvtjsXBvft7UVNVqNlC/Com1gG0y/KTQ6rrdLdfFv7WgD0YP6UNkfdaYpg==} + engines: {node: '>=20'} + peerDependencies: + '@missing-elements/h5p-runtime': ^1.0.0 + peerDependenciesMeta: + '@missing-elements/h5p-runtime': + optional: true + + '@missing-elements/h5p-runtime@1.0.1': + resolution: {integrity: sha512-/cvjh7a9X9DMHEzIOqCMJAngEJxrCOCr33NI+FOM5UGdXR7Er0CYpMqQKGslrS2hVJIlWJ9iI8YG/Bw2/vISlA==} + engines: {node: '>=20'} + '@missing-elements/pdfjs-viewer@4.5.0': resolution: {integrity: sha512-BxwuHJxt0PYGNYweqQzV5XBHxdFmZRrmyrnDyQUmNvUPsdAMja+Ms86Kxz+9ruWrntw1Zg/gX5kr2o/en6wo4Q==} @@ -150,6 +201,107 @@ packages: resolution: {integrity: sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw==} engines: {node: '>= 10'} + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm-eabi@1.2.18': + resolution: {integrity: sha512-ubTG+4KlidvioxjOFdsdEa2F6JVGZb8m2ByJUqQ7fYzUeT1cEn194POcsQ10PKH4p//q0YQn4SB3YpSuzcA1Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.18': + resolution: {integrity: sha512-2Xa1t/JeFGTZn5TbzgPppcCftF8sCVnTNvrIvoSdcZhoFzAuVy1L3xxQUQ4sfWDtwVzg1u6Yi1R3SlJugRNV2Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.18': + resolution: {integrity: sha512-iUzw9HshVvrWci9gVXjxWS7/M/IcLvNo67vu2c/w9zKBz4hIvPRixdUpIW/iCfhldLaq9ER2hecsI9UY5z0/yA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.18': + resolution: {integrity: sha512-XuDHFzbFEtZl0QGJi92+fmG67tfnp1iZ9VOeDkwasGyTq2VG3/5PnJnvAr+MTzFh/rNScJ3VhN+XpZyyGsN7Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.18': + resolution: {integrity: sha512-MM8opLfsLK15oEXDz6iBc0t3ahu+ew9jsAmha+ovZ33hSwYgqo7YmpRITqFTfW7xA71BLEQ3jJdTsaCmhB1c9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.18': + resolution: {integrity: sha512-J6EoCWiy5RnFQzHrKyHXXevZ9lukQfGpnchX/zG/k/WC2nc9O9XyS1JgA8kI9VxMFggMbCPjpv7LcikriXfehw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.18': + resolution: {integrity: sha512-HIkFuLF7CdDeki+1aCZLLbLq0hk/PHntb+z4/YCTo47JbksTD6jJEELIz92tYROmDmvMrNqUMqBi71BkJLDR+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.18': + resolution: {integrity: sha512-4oqWItLLYt7IceOnElCct8T2ek5p7PmD7LN04TRJRXGo5f3HtKTgGH6PhYRFK4C2ICkq/LMUU0BVPvvlAKhm8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.18': + resolution: {integrity: sha512-+AkRly5SrCEgnPhGRfub7pucASYEC2fTsxZg7VCcnozpA2+Wn/gCYy1t7LuJS67/xyJu/7noYBJncDUkbwgw2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.18': + resolution: {integrity: sha512-Ttg1r9vVykMnQrE/GwYO9icKtXiuiMfRe/w74xJkeoVuvhRG+WtT6LEMOf58Yl63yd77lhHzO8Y55R8CC3XALA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.18': + resolution: {integrity: sha512-xX1Tt4o5jUMma0vJsXp5eRZA9nOuyigb2dKJMMzZRmzMxLYGE9PaKKSqHPzPvSQgM8ZOEacSTQwDQcwWfh0xmQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.18': + resolution: {integrity: sha512-4VVlGI5K2kociZlW3lSN8VjG3TW5yQIlAcfCdBXMgGjJxZll15Fa4ocRHXHvIVVnwlibCw/NQajJrNDiqqpiiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.18': + resolution: {integrity: sha512-PApTsgH1jLYuyy3tSCz9De82Gq5qXwi7vImVZhEzTgX1C4150Cp5M2ZfL3pLvbCvbEHL0oJeC6pd6podtv8Y5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.18': + resolution: {integrity: sha512-J2XjWjx/SP+4x0H4r6dz3Vzb5WVkg7re40Do9K7HJ3sK2/xcmZrWwakXlSwaMvOkvruLJCD4CL/3wqsIR/thgg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.18': + resolution: {integrity: sha512-XwRJxRQvkuEtS0hIIt29QrqPJ+JzMJSNYHJw4ae/mc6uEarfRG1ceyg4HmmWiBdL/YCb6g7N5BLbSvmRJRYZHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.1.0': + resolution: {integrity: sha512-K+NU2HDCEem4sClgHH4ZZXwdJeNFkJo+hRk479DIwdIviAPN2hFJH1KHHwg8CxHQXvi0M5XY4deTXINfIiSG6w==} + '@sindresorhus/slugify@2.2.1': resolution: {integrity: sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==} engines: {node: '>=12'} @@ -158,6 +310,133 @@ packages: resolution: {integrity: sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==} engines: {node: '>=12'} + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@zip.js/zip.js@2.24.0': + resolution: {integrity: sha512-WlaVKzdqBK+RF3bdiXZWbdCnvGB6PdO4IHC1mn/2Yfl3bASgqUNegnU3+PNg61iecwoPfUi5BgLUPqBukXpyWw==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + a-sync-waterfall@1.0.1: resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} @@ -231,6 +510,10 @@ packages: resolution: {integrity: sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==} engines: {node: '>=4'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} @@ -313,6 +596,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + htmlparser2@7.2.0: resolution: {integrity: sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==} @@ -363,6 +651,76 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} @@ -420,6 +778,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + node-retrieve-globals@6.0.1: resolution: {integrity: sha512-j0DeFuZ/Wg3VlklfbxUgZF/mdHMTEiEipBb3q0SpMMbHaV3AVfoUQF8UGxh1s/yjqO0TgRZd4Pi/x2yRqoQ4Eg==} @@ -442,13 +805,30 @@ packages: resolution: {integrity: sha512-vg8Pw1x+AFKy4AMDvhuWl5cOYV8d698UoEANoFFhc79hqXDnZQqnOXoxw3HG907WHOg1JYOvLhP756boddFxpA==} engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + please-upgrade-node@3.2.0: resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + posthtml-match-helper@2.0.3: resolution: {integrity: sha512-p9oJgTdMF2dyd7WE54QI1LvpBIkNkbSiiECKezNnDVYhGhD1AaOnAkw0Uh0y5TW+OHO8iBdSqnd8Wkpb6iUqmw==} engines: {node: '>=18'} @@ -486,6 +866,11 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + rolldown@1.2.18: + resolution: {integrity: sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -513,6 +898,10 @@ packages: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + ssri@11.0.0: resolution: {integrity: sha512-aZpUoMN/Jj2MqA4vMCeiKGnc/8SuSyHbGSBdgFbZxP8OJGF/lFkIuElzPxsN0q8TQQ+prw3P4EDfB3TBHHgfXw==} engines: {node: ^16.14.0 || >=18.0.0} @@ -533,9 +922,17 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -543,6 +940,49 @@ packages: urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + ws@8.22.0: resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} @@ -678,6 +1118,29 @@ snapshots: minimatch: 3.1.5 slash: 3.0.0 + '@missing-elements/h5p-author@0.6.0(@missing-elements/h5p-runtime@1.0.1)': + dependencies: + '@missing-elements/h5p-libraries': 1.1.0 + '@missing-elements/h5p-normalize': 1.3.0 + '@missing-elements/h5p-offline-player': 1.5.1(@missing-elements/h5p-runtime@1.0.1) + '@zip.js/zip.js': 2.24.0 + transitivePeerDependencies: + - '@missing-elements/h5p-runtime' + + '@missing-elements/h5p-libraries@1.1.0': {} + + '@missing-elements/h5p-normalize@1.3.0': + dependencies: + '@zip.js/zip.js': 2.24.0 + + '@missing-elements/h5p-offline-player@1.5.1(@missing-elements/h5p-runtime@1.0.1)': + dependencies: + '@zip.js/zip.js': 2.24.0 + optionalDependencies: + '@missing-elements/h5p-runtime': 1.0.1 + + '@missing-elements/h5p-runtime@1.0.1': {} + '@missing-elements/pdfjs-viewer@4.5.0': dependencies: pdfjs-dist: 6.5.438 @@ -730,6 +1193,59 @@ snapshots: '@napi-rs/canvas-win32-x64-msvc': 1.0.13 optional: true + '@oxc-project/types@0.156.0': {} + + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + + '@rolldown/binding-android-arm-eabi@1.2.18': + optional: true + + '@rolldown/binding-android-arm64@1.2.18': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.18': + optional: true + + '@rolldown/binding-darwin-x64@1.2.18': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.18': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.18': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.18': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.18': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.18': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.18': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.18': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.18': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.18': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.18': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.18': + optional: true + + '@rolldown/pluginutils@1.1.0': {} + '@sindresorhus/slugify@2.2.1': dependencies: '@sindresorhus/transliterate': 1.6.0 @@ -739,6 +1255,72 @@ snapshots: dependencies: escape-string-regexp: 5.0.0 + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@zip.js/zip.js@2.24.0': {} + a-sync-waterfall@1.0.1: {} acorn-walk@8.3.5: @@ -793,6 +1375,8 @@ snapshots: dependency-graph@1.0.0: {} + detect-libc@2.1.2: {} + dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -863,6 +1447,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.3: + optional: true + htmlparser2@7.2.0: dependencies: domelementtype: 2.3.0 @@ -907,6 +1494,55 @@ snapshots: kleur@4.1.5: {} + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -954,6 +1590,8 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.20: {} + node-retrieve-globals@6.0.1: dependencies: acorn: 8.19.0 @@ -974,12 +1612,26 @@ snapshots: optionalDependencies: '@napi-rs/canvas': 1.0.13 + picocolors@1.1.1: {} + picomatch@4.0.7: {} + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + please-upgrade-node@3.2.0: dependencies: semver-compare: 1.0.0 + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + posthtml-match-helper@2.0.3(posthtml@0.16.7): dependencies: posthtml: 0.16.7 @@ -1007,6 +1659,27 @@ snapshots: readdirp@4.1.2: {} + rolldown@1.2.18: + dependencies: + '@oxc-project/types': 0.156.0 + '@rolldown/pluginutils': 1.1.0 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.18 + '@rolldown/binding-android-arm64': 1.2.18 + '@rolldown/binding-darwin-arm64': 1.2.18 + '@rolldown/binding-darwin-x64': 1.2.18 + '@rolldown/binding-freebsd-x64': 1.2.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.18 + '@rolldown/binding-linux-arm64-gnu': 1.2.18 + '@rolldown/binding-linux-arm64-musl': 1.2.18 + '@rolldown/binding-linux-ppc64-gnu': 1.2.18 + '@rolldown/binding-linux-s390x-gnu': 1.2.18 + '@rolldown/binding-linux-x64-gnu': 1.2.18 + '@rolldown/binding-linux-x64-musl': 1.2.18 + '@rolldown/binding-openharmony-arm64': 1.2.18 + '@rolldown/binding-win32-arm64-msvc': 1.2.18 + '@rolldown/binding-win32-x64-msvc': 1.2.18 + section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -1038,6 +1711,8 @@ snapshots: slugify@1.6.9: {} + source-map-js@1.2.2: {} + ssri@11.0.0: dependencies: minipass: 7.1.3 @@ -1053,10 +1728,46 @@ snapshots: toidentifier@1.0.1: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + uc.micro@2.1.0: {} + undici-types@8.9.0: {} + unpipe@1.0.0: {} urlpattern-polyfill@10.1.0: {} + vite@8.3.5(@types/node@26.6.6): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.32 + rolldown: 1.2.18 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.6 + fsevents: 2.3.3 + ws@8.22.0: {} diff --git a/src/_includes/base.njk b/src/_includes/base.njk index fa3ddcc..c70c789 100644 --- a/src/_includes/base.njk +++ b/src/_includes/base.njk @@ -7,6 +7,9 @@ + {%- if csp %} + + {%- endif %} {{ title }} diff --git a/src/_includes/pdfjs-viewer.njk b/src/_includes/pdfjs-viewer.njk index b468197..effd940 100644 --- a/src/_includes/pdfjs-viewer.njk +++ b/src/_includes/pdfjs-viewer.njk @@ -21,12 +21,7 @@ bodyClass: docs-page {{ site.name }}

pdfjs-viewer

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

A self-hostable PDF viewer web component, based on the PDF.js default viewer.

    diff --git a/src/_includes/theme-switch.njk b/src/_includes/theme-switch.njk new file mode 100644 index 0000000..db3d6c3 --- /dev/null +++ b/src/_includes/theme-switch.njk @@ -0,0 +1,7 @@ +{#- The theme switch, which site.js wires up. Indented for where pdfjs-viewer.njk includes it, so that page is as it was. -#} + \ No newline at end of file diff --git a/src/assets/site.css b/src/assets/site.css index 573133b..9b4cbbc 100644 --- a/src/assets/site.css +++ b/src/assets/site.css @@ -545,3 +545,70 @@ tbody th a:hover code { text-decoration: underline; } .elements h2 { margin: 0 0 0.25rem; padding: 0; border: 0; font-size: 1.125rem; } .elements p { margin: 0 0 0.5rem; color: var(--muted); } .elements .links { display: flex; gap: 1rem; font-size: 0.875rem; } + +/* H5P build page, /h5p/build/ */ +.build { --ok: var(--live); --bad: var(--t-tag); } +.build-brand { margin-bottom: 1.25rem; font-size: 0.9375rem; } +/* A longer heading than the docs' package names, so it gives way on a phone. */ +.build .docs-header h1 { font-size: clamp(1.75rem, 1.2rem + 3vw, 2.25rem); } +.build .step h2 { display: flex; align-items: center; gap: 0.6rem; margin: 2.5rem 0 1rem; } +.build h3 { margin: 1.5rem 0 0.25rem; font-size: 1rem; } +.step-number { + display: inline-grid; + place-items: center; + flex: none; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + background: var(--accent); + color: var(--bg); + font-size: 0.9375rem; +} +.build label { display: block; margin-bottom: 0.35rem; font-weight: 600; } +.build label.inline-label { display: inline; margin-right: 0.5rem; } +.build textarea { + display: block; + width: 100%; + min-height: 12rem; + resize: vertical; + padding: 0.6rem 0.75rem; + border: 1px solid var(--control); + border-radius: var(--radius); + background: var(--raised); + color: var(--text); + font: 0.8125rem/1.6 var(--mono); +} +.build textarea:hover { border-color: var(--text); } +.build input[type="file"] { max-width: 100%; font: inherit; font-size: 0.875rem; color: var(--muted); } +.build input[type="file"]::file-selector-button { + margin-right: 0.6rem; + padding: 0.3rem 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--raised); + color: var(--text); + font: inherit; + cursor: pointer; +} +.build input[type="file"]::file-selector-button:hover { background: var(--tint); } +.build a.button { text-decoration: none; } +.button-large { padding: 0.45rem 1rem; font-size: 0.9375rem; } +.build .button:disabled { opacity: 0.6; cursor: progress; } +.build .status { + padding: 0.6rem 0.85rem; + border-left: 3px solid var(--border); + border-radius: 0 var(--radius) var(--radius) 0; + background: var(--surface); +} +.build .status:empty { display: none; } +.build .status[data-state="building"] { border-left-color: var(--focus); background: var(--tint); } +.build .status[data-state="ready"] { border-left-color: var(--ok); } +.build .status[data-state="error"] { border-left-color: var(--bad); } +.build .messages, .build .file-list { padding-left: 1.2rem; } +.build .problems li::marker { color: var(--bad); } +.build .file-list li[data-chosen="true"]::marker { color: var(--ok); } +.build .file-list li[data-chosen="false"] { color: var(--muted); } +.build .hint { color: var(--muted); font-size: 0.875rem; } +/* H5P content draws on white, whatever the page's theme. */ +.player-box { margin: 1rem 0; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: #ffffff; } +.player-box h5p-player { display: block; } diff --git a/src/h5p/build/build-page.ts b/src/h5p/build/build-page.ts new file mode 100644 index 0000000..1bb57a5 --- /dev/null +++ b/src/h5p/build/build-page.ts @@ -0,0 +1,226 @@ +import '@missing-elements/h5p-offline-player' +import { buildPackage, openPack, SourceError } from '@missing-elements/h5p-author/browser' +import { pack as installPack } from '@missing-elements/h5p-libraries/install' +import { runtime } from '@missing-elements/h5p-runtime' +import { forTheChat, matchFiles, namedFiles, packageName, parseSource, sized, sourceFromFragment, type Problem } from './build-source' + +/** + * The build page, `/h5p/build/`: a source an AI chat wrote with the h5p-author skill becomes a + * `.h5p` file here, in the browser, for a chat whose sandbox cannot install the build tool + * (ChatGPT's npm mirror was reported refusing installs). The build is + * `@missing-elements/h5p-author/browser`, the same build as the command's; its libraries are + * `@missing-elements/h5p-libraries/install`'s pack, which Vite emits into `/h5p/assets/` one part + * per library, the build fetching only the parts a package needs. The package then plays in the + * player on this page and downloads from it. Nothing leaves the browser: a link that carries a + * source carries it in its fragment, `#source=`, which no server sees. The page's markup is + * `index.njk` beside this file, and its styles are the site's own, in `site.css`. + */ + +type Opened = Awaited> + +const $ = (id: string): T => { + const element = document.getElementById(id) + if (!element) throw new Error(`The page has no #${id}`) + return element as T +} + +const sourceField = $('source') +const sourceFile = $('source-file') +const filesStep = $('files-step') +const fileList = $('file-list') +const filesInput = $('files') +const buildButton = $('build') +const buildNumber = $('build-number') +const playNumber = $('play-number') +const status = $('status') +const problemsBox = $('problems-box') +const problemsList = $('problems') +const copyProblems = $('copy-problems') +const copyResult = $('copy-result') +const notesBox = $('notes-box') +const notesList = $('notes') +const playStep = $('play-step') +const download = $('download') +const player = $('player') as HTMLElementTagNameMap['h5p-player'] + +player.runtime = runtime + +/** The pack, opened at the first build and kept for the page's builds. */ +let opened: Promise | null = null +/** The files chosen so far, by name; a file chosen again under the same name replaces the first. */ +const chosen = new Map() +/** The problems last shown, for the copy button. */ +let shownProblems: Problem[] = [] +/** The address of the package last built, given back when the next one replaces it. */ +let packageUrl: string | null = null +/** Counts builds, so that one overtaken by a newer one shows nothing. */ +let builds = 0 + +function setStatus(text: string, state: 'idle' | 'building' | 'ready' | 'error'): void { + status.textContent = text + status.dataset.state = state +} + +function fillList(list: HTMLUListElement, items: Problem[]): void { + list.replaceChildren(...items.map(({ path, message }) => { + const item = document.createElement('li') + const where = document.createElement('code') + where.textContent = path + item.append(where, ` ${message}`) + return item + })) +} + +function showProblems(problems: Problem[]): void { + shownProblems = problems + fillList(problemsList, problems) + problemsBox.hidden = problems.length === 0 + copyResult.textContent = '' +} + +function showNotes(notes: Problem[]): void { + fillList(notesList, notes) + notesBox.hidden = notes.length === 0 +} + +/** The step numbers, with the Files step shown or not. */ +function number(withFiles: boolean): void { + filesStep.hidden = !withFiles + buildNumber.textContent = withFiles ? '3' : '2' + playNumber.textContent = withFiles ? '4' : '3' +} + +/** The source's files, each marked chosen or not; the step is shown only for a source that names some. */ +function updateFiles(): string[] { + const parsed = parseSource(sourceField.value) + const paths = 'source' in parsed ? namedFiles(parsed.source) : [] + const { files } = matchFiles(paths, [...chosen.values()]) + fileList.replaceChildren(...paths.map((path) => { + const item = document.createElement('li') + const name = document.createElement('code') + name.textContent = path + const file = files[path] + item.dataset.chosen = String(Boolean(file)) + item.append(name, file ? ` chosen, ${sized(file.size)}` : ' not chosen yet') + return item + })) + number(paths.length > 0) + return paths +} + +/** What the build says beyond its notes: interface texts it could not translate. */ +function translationNotes(language: string, translations: Awaited>['translations']): Problem[] { + if (/^en(?:-|$)/i.test(language)) return [] + const notes: Problem[] = [] + for (const { library, translation, strings, english } of translations) { + if (!translation) notes.push({ path: library, message: `has no translation for ${language}: its ${strings} interface texts are English.` }) + else if (english.length) notes.push({ path: library, message: `${english.length} of ${strings} interface texts are as in English, untranslated or the same word: ${english.map(({ field }) => field).join(', ')}.` }) + } + return notes +} + +async function build(): Promise { + const run = ++builds + showProblems([]) + showNotes([]) + const parsed = parseSource(sourceField.value) + if ('problem' in parsed) { + setStatus('The source cannot be read.', 'error') + showProblems([parsed.problem]) + return + } + const { files } = matchFiles(updateFiles(), [...chosen.values()]) + setStatus('Building…', 'building') + buildButton.disabled = true + try { + opened ??= openPack(installPack) + const libraries = await opened + const result = await buildPackage(parsed.source, { libraries, files }) + if (run !== builds) return + const title = String(result.manifest.title ?? '') + const name = packageName(title) + if (packageUrl) URL.revokeObjectURL(packageUrl) + packageUrl = URL.createObjectURL(result.blob) + download.href = packageUrl + download.download = name + download.textContent = `Download ${name}` + showNotes([...result.notes, ...translationNotes(String(result.manifest.language ?? ''), result.translations)]) + const media = result.media.length ? `, ${result.media.length} media file${result.media.length === 1 ? '' : 's'}` : '' + setStatus(`Built “${title}”: ${String(result.manifest.mainLibrary)}, ${result.libraries.length} libraries${media}, ${sized(result.blob.size)}. Starting it below…`, 'building') + playStep.hidden = false + player.file = new File([result.blob], name, { type: 'application/zip' }) + } catch (error) { + if (run !== builds) return + if (error instanceof SourceError) { + const problems = (error as SourceError & { problems: Problem[] }).problems + setStatus(`The source has ${problems.length} problem${problems.length === 1 ? '' : 's'}; nothing was built.`, 'error') + showProblems(problems) + } else { + setStatus(`The build failed: ${error instanceof Error ? error.message : error}`, 'error') + // A pack that could not open is opened again at the next build. + opened = null + } + } finally { + if (run === builds) buildButton.disabled = false + } +} + +player.addEventListener('ready', () => { + if (status.dataset.state !== 'building') return + setStatus(status.textContent!.replace(/ Starting it below…$/, ' It plays below.'), 'ready') +}) +player.addEventListener('error', (event) => { + const { message } = (event as unknown as CustomEvent<{ message: string }>).detail + if (player.state === 'ready') return // the content runs on; the frame logs it + setStatus(`It was built, but does not play: ${message}`, 'error') +}) + +let typing: ReturnType | undefined +sourceField.addEventListener('input', () => { + clearTimeout(typing) + typing = setTimeout(updateFiles, 300) +}) + +sourceFile.addEventListener('change', async () => { + const file = sourceFile.files?.[0] + if (!file) return + sourceField.value = await file.text() + updateFiles() +}) + +filesInput.addEventListener('change', () => { + for (const file of filesInput.files ?? []) chosen.set(file.name, file) + filesInput.value = '' + updateFiles() +}) + +buildButton.addEventListener('click', () => void build()) + +copyProblems.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(forTheChat(shownProblems)) + copyResult.textContent = 'Copied.' + } catch { + copyResult.textContent = 'The browser did not allow copying; select the list and copy it.' + } +}) + +/** A link's source is built at once, unless it names files, which only the person can choose. */ +async function fromLink(): Promise { + let text: string | null + try { + text = await sourceFromFragment(location.hash) + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error), 'error') + return + } + if (text === null) return + sourceField.value = text + const paths = updateFiles() + if (paths.length) setStatus('The source names files: choose them, then Build.', 'idle') + else await build() +} + +updateFiles() +void fromLink() +window.addEventListener('hashchange', () => void fromLink()) diff --git a/src/h5p/build/build-source.ts b/src/h5p/build/build-source.ts new file mode 100644 index 0000000..7dbe51c --- /dev/null +++ b/src/h5p/build/build-source.ts @@ -0,0 +1,132 @@ +/** + * What the build page does with a source before and after `@missing-elements/h5p-author` sees it, + * with no DOM: the source a link carries, the files a source names, and the lines a chat is given + * back. The browser tests reach it through the page. + */ + +/** A problem or a note, as the author package reports them: where in the source, and what. */ +export interface Problem { + path: string + message: string +} + +/** + * The source a link carries in its fragment, `#source=`, which the browser never + * sends to a server. The bytes are the source's JSON, as UTF-8, or that JSON compressed with + * raw deflate, which a chat's sandbox writes with Node's `zlib.deflateRawSync` or Python's + * `zlib.compressobj(wbits=-15)` to keep the link short. Returns `null` for a fragment that + * carries none, and throws for one that carries something else. + */ +export async function sourceFromFragment(hash: string): Promise { + const value = new URLSearchParams(hash.replace(/^#/, '')).get('source') + if (!value) return null + let bytes: Uint8Array + try { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '') + bytes = Uint8Array.from(atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4)), (char) => char.charCodeAt(0)) + } catch { + throw new Error('The link’s #source= is not base64url') + } + const text = new TextDecoder('utf-8', { fatal: false }).decode(bytes) + if (isJson(text)) return text + try { + const inflated = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')) + const inflatedText = await new Response(inflated).text() + if (isJson(inflatedText)) return inflatedText + } catch { + // Not deflate either; said below. + } + throw new Error('The link’s #source= is neither JSON nor deflated JSON in base64url') +} + +function isJson(text: string): boolean { + if (!/^\s*\{/.test(text)) return false + try { + JSON.parse(text) + return true + } catch { + return false + } +} + +/** + * Reads the text as a source, or says where it is not JSON, as a problem the chat can fix. + */ +export function parseSource(text: string): { source: unknown } | { problem: Problem } { + if (!text.trim()) return { problem: { path: 'source', message: 'is empty: paste the JSON the chat wrote' } } + // A chat often hands the source inside a Markdown code fence; take what is inside it. + const fenced = /^\s*```[\w-]*\s*\n([\s\S]*?)\n\s*```\s*$/.exec(text) + try { + return { source: JSON.parse(fenced ? fenced[1] : text) } + } catch (error) { + return { problem: { path: 'source', message: `is not valid JSON: ${error instanceof Error ? error.message : error}` } } + } +} + +/** Whether a file's `path` is a web address, which the build fetches itself, rather than a file to choose. */ +const isAddress = (path: string): boolean => /^(?:https?|data):/i.test(path.trim()) + +/** + * The files a source names by name rather than by web address: every `{ "path": … }` in it, in + * the order they come. The author package looks each one up by the path as written. + */ +export function namedFiles(source: unknown): string[] { + const found = new Set() + const walk = (value: unknown): void => { + if (Array.isArray(value)) value.forEach(walk) + else if (value && typeof value === 'object') { + const path = (value as { path?: unknown }).path + if (typeof path === 'string' && path.trim() && !isAddress(path)) found.add(path) + Object.values(value).forEach(walk) + } + } + walk(source) + return [...found] +} + +/** A path's last part, which is what a file chosen from a computer is called. */ +export const baseName = (path: string): string => path.slice(path.replace(/\\/g, '/').lastIndexOf('/') + 1) + +/** + * The files to build with: each path the source names, matched to a chosen file by its name, + * keyed by the path as the source writes it. + */ +export function matchFiles(paths: string[], chosen: File[]): { files: Record, missing: string[] } { + const byName = new Map(chosen.map((file) => [file.name, file])) + const files: Record = {} + const missing: string[] = [] + for (const path of paths) { + const file = byName.get(baseName(path)) + if (file) files[path] = file + else missing.push(path) + } + return { files, missing } +} + +/** The problems, as the text a person pastes back into the chat that wrote the source. */ +export function forTheChat(problems: Problem[]): string { + const count = `${problems.length} problem${problems.length === 1 ? '' : 's'}` + return [ + `The build page found ${count} in the source. Fix them all and write the whole source again:`, + ...problems.map(({ path, message }) => `- ${path}: ${message}`) + ].join('\n') +} + +/** + * A file name for the package, from its title: its letters, marks and digits in any script, lower + * case, joined by hyphens, at most 60 of them. "Die größten Ereignisse" is + * `die-größten-ereignisse.h5p`, and a Cyrillic or Greek title keeps its letters, where a slug of + * ASCII alone turned ß into a hyphen and such a title into `activity.h5p`. + */ +export function packageName(title: unknown): string { + const words = String(title ?? '') + .normalize('NFC') + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}]+/gu, '-') + .replace(/^-+|-+$/g, '') + const short = Array.from(words).slice(0, 60).join('').replace(/-+$/, '') + return `${short || 'activity'}.h5p` +} + +/** A size in kilobytes below a megabyte, in megabytes above. */ +export const sized = (bytes: number): string => (bytes < 1_000_000 ? `${Math.max(1, Math.round(bytes / 1000))} kB` : `${(bytes / 1_000_000).toFixed(1)} MB`) diff --git a/src/h5p/build/index.njk b/src/h5p/build/index.njk new file mode 100644 index 0000000..79ab861 --- /dev/null +++ b/src/h5p/build/index.njk @@ -0,0 +1,97 @@ +--- +layout: base.njk +title: Build an H5P activity · Missing Elements +description: Turn the source an AI chat wrote with the h5p-author skill into a .h5p file, in your browser. Nothing is uploaded. +# The page's only policy: GitHub Pages sends no headers. Packages it built play in the player's frame, +# under the policy the player's Service Worker gives each response there. A file a source names by +# web address is fetched by the build, so any host may be connected to. +csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src * data: blob:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'" +--- +{#- The markup build-page.ts wires up, built into /h5p/build/build-page.js by Vite (vite.config.ts). -#} +
    +
    + {{ site.name }} +
    +

    Build an H5P activity

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

    + For an AI chat that wrote a source with the + h5p-author skill + but could not build it, as in ChatGPT, where the build tool does not install. +

    +

    + Paste the source here, play the activity, and download a .h5p file that any H5P + site takes. Nothing is uploaded: the package is built in your browser, from the libraries this + site serves. +

    +
    + +
    +

    1 Source

    +

    + + +

    +

    + + +

    +
    + + + +
    +

    2 Build

    +

    +

    + + +
    + + + + +
    + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ffc83c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vite/client", + "node" + ], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": [ + "src/h5p", + "e2e", + "playwright.config.ts", + "vite.config.ts" + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9b4444b --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,144 @@ +import { 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 { writeBundles } from '@missing-elements/h5p-author' +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/bundles/`, for a chat that reaches no npm registry (see `bundles()`). + * + * 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' + +/** + * 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, '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/bundles/`, for a chat that runs Node and can fetch a file but reaches no npm registry, as + * in ChatGPT's sandbox. The h5p-author skill builds there from two files fetched from here: the + * command line, and the bundle of the source's content 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`. + * - `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, nor the package.json `--version` reads, so `index.json` says the versions. + * - `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' } + }) + const listing = { + author: await version(author), + pack: await version(libraries), + tool: 'h5p-author.mjs', + parts: 'parts/', + licences: 'libraries.txt', + 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`) + } + } +} + +export default defineConfig({ + publicDir: false, + plugins: [emptyOwnFolders(), licenceFiles(), bundles()], + build: { + outDir: '_site', + emptyOutDir: false, + assetsDir: ASSETS, + rolldownOptions: { + input: { 'build-page': 'src/h5p/build/build-page.ts' }, + output: { entryFileNames: 'h5p/build/[name].js' } + } + } +}) From 5f4902d6a78c6f6c4fe67622a408ba2332e01e3d Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Tue, 10 Nov 2026 03:30:35 +0200 Subject: [PATCH 2/2] Put the build page's policy first, and fix the review's other findings - base.njk writes a page's Content-Security-Policy first in , before the inline color-scheme script, and an Eleventy transform adds each inline script's hash to script-src, so the policy covers every script on the page. - The test PNG's IDAT failed its CRC and zlib check; it is a valid 2x2 red PNG now, and the test checks the picture reaches the content. - The README describes embed-my's /build redirect as a step of its own, and says the bundles test runs unzip, as the skill's steps do. --- README.md | 4 ++-- e2e/build.spec.ts | 4 +++- eleventy.config.js | 14 ++++++++++++++ src/_includes/base.njk | 6 +++--- src/h5p/build/index.njk | 8 +++++--- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7f03167..ec30dcd 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ pnpm e2e # the build page and the bundles in a browser, against _site/: bu # per machine: pnpm exec playwright install chromium ``` -`pnpm dev` serves the pages as Eleventy writes them; the build page's script is there only after a `pnpm build`. +`pnpm dev` serves the pages as Eleventy writes them; the build page's script is there only after a `pnpm build`. The bundles test unzips a bundle with `unzip`, as the h5p-author skill's steps do: macOS and Linux have it, and so does CI's Ubuntu runner; on Windows, run the tests under WSL. Pushes to `main` deploy through [.github/workflows/pages.yml](.github/workflows/pages.yml). Pull requests get a build check from [.github/workflows/build.yml](.github/workflows/build.yml), which runs the browser tests too. @@ -66,7 +66,7 @@ Nothing leaves the browser. A link can carry the source in its fragment, which n 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. -The page and the bundles were embed-my.github.io's `/build` and `/bundles/` until 2026-11-10. `/build` there now redirects here with its fragment, so the links chats wrote before still build. +The page and the bundles were embed-my.github.io's `/build` and `/bundles/` until 2026-11-10. Its `/build` becomes a redirect here that keeps the fragment, so the links chats wrote before still build: [embed-my/embed-my.github.io#18](https://github.com/embed-my/embed-my.github.io/pull/18), a step of its own, deployed once this page is live. ## The bundles diff --git a/e2e/build.spec.ts b/e2e/build.spec.ts index 9fa2d40..188c68f 100644 --- a/e2e/build.spec.ts +++ b/e2e/build.spec.ts @@ -19,7 +19,7 @@ const RIVERS = { } /** A 2×2 PNG, red. */ -const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP4z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg==', 'base64') +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR42mP4z8AARAwQCgAf7gP9Y167WwAAAABJRU5ErkJggg==', 'base64') /** Collects console errors, CSP reports, page errors and requests that leave the origin. */ function watch(page: Page, baseURL: string | undefined): { problems: string[], elsewhere: string[] } { @@ -170,5 +170,7 @@ test('a source that names a file waits for it, then builds with it', async ({ pa await page.locator('#build').click() await expectPlays(page) await expect(page.locator('#status')).toContainText('1 media file') + // The picture reaches the content and decodes: 2×2, as chosen. + await expect.poll(() => page.frameLocator('h5p-player iframe').first().locator('img').first().evaluate((img: HTMLImageElement) => img.naturalWidth)).toBe(2) expect(problems, problems.join('\n')).toEqual([]) }) diff --git a/eleventy.config.js b/eleventy.config.js index 5e2817c..81c732d 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight"; import attributes from "./src/_data/pdfjsAttributes.js"; @@ -16,6 +17,19 @@ export default function (eleventyConfig) { // 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" }); + // 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: + // the color-scheme script has to run before the page paints, so it stays inline. + eleventyConfig.addTransform("csp-inline-script-hashes", function (content) { + if (!(this.page.outputPath || "").endsWith(".html")) return content; + const scriptSrc = /]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)] + .map(([, source]) => ` 'sha256-${createHash("sha256").update(source).digest("base64")}'`) + .join(""); + return content.replace(scriptSrc, (directive) => directive + hashes); + }); + let markdown; eleventyConfig.amendLibrary("md", (md) => { markdown = md; diff --git a/src/_includes/base.njk b/src/_includes/base.njk index c70c789..1358e04 100644 --- a/src/_includes/base.njk +++ b/src/_includes/base.njk @@ -2,14 +2,14 @@ + {%- if csp %} + + {%- endif %} - {%- if csp %} - - {%- endif %} {{ title }} diff --git a/src/h5p/build/index.njk b/src/h5p/build/index.njk index 79ab861..2e1d954 100644 --- a/src/h5p/build/index.njk +++ b/src/h5p/build/index.njk @@ -2,9 +2,11 @@ layout: base.njk title: Build an H5P activity · Missing Elements description: Turn the source an AI chat wrote with the h5p-author skill into a .h5p file, in your browser. Nothing is uploaded. -# The page's only policy: GitHub Pages sends no headers. Packages it built play in the player's frame, -# under the policy the player's Service Worker gives each response there. A file a source names by -# web address is fetched by the build, so any host may be connected to. +# The page's only policy: GitHub Pages sends no headers. base.njk writes it first in , and the +# site's inline color-scheme script is added to script-src by its hash at build (eleventy.config.js). +# Packages it built play in the player's frame, under the policy the player's Service Worker gives +# each response there. A file a source names by web address is fetched by the build, so any host may +# be connected to. csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; font-src 'self'; connect-src * data: blob:; worker-src 'self' blob:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'" --- {#- The markup build-page.ts wires up, built into /h5p/build/build-page.js by Vite (vite.config.ts). -#}