From a8711ee7bb60c4d9e0db97eca15d335a4abe57e9 Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Wed, 11 Nov 2026 13:27:02 +0200 Subject: [PATCH 1/3] The source arrives laid out, and a link that carried it follows it The Format button goes: a source that arrives is laid out and taken out of its code fence, so nobody has to ask. A source opened from a link or a file, and one pasted over all of the text (or into an empty editor), arrives that way; a paste into the text, and text that is not JSON, go in as they are. Where the editor cannot load, the textarea lays a whole paste out too. A page opened by a link that carried a source (#source=) now keeps the link in step: when the source changes to another JSON source, the address is replaced, not added to the history, with the new source on one line, deflated where the browser can. A change of layout, and text that is not JSON, leave it as it was; an emptied editor leaves #source= with nothing after it, so the next source is written; a link that cannot be read gives way to the source the person writes. A page opened with no source in its address never writes one. formatSource moves to json-scan.ts, which the editor's chunk can use without the author package. --- e2e/build.spec.ts | 183 ++++++++++++++++++++++++++++++++-- src/assets/site.css | 5 +- src/h5p/build/build-page.ts | 89 ++++++++++++----- src/h5p/build/build-source.ts | 46 +++++++-- src/h5p/build/editor.ts | 23 ++++- src/h5p/build/index.njk | 13 ++- src/h5p/build/json-scan.ts | 10 ++ 7 files changed, 313 insertions(+), 56 deletions(-) diff --git a/e2e/build.spec.ts b/e2e/build.spec.ts index d083323..e61b6bd 100644 --- a/e2e/build.spec.ts +++ b/e2e/build.spec.ts @@ -1,5 +1,5 @@ import { readdir, readFile } from 'node:fs/promises' -import { deflateRawSync } from 'node:zlib' +import { deflateRawSync, inflateRawSync } from 'node:zlib' import { expect, test, type APIRequestContext, type Page } from '@playwright/test' /* @@ -64,6 +64,12 @@ async function fillSource(page: Page, text: string): Promise { await expect(page.locator('#source')).toHaveValue(text) } +/** Pastes text through the clipboard into what has the focus, as a person does: the browser's own paste. */ +async function paste(page: Page, text: string): Promise { + await page.evaluate((value) => navigator.clipboard.writeText(value), text) + await page.keyboard.press('ControlOrMeta+v') +} + const playerState = (page: Page) => page.locator('h5p-player').evaluate((element: HTMLElement & { state?: string }) => element.state) async function expectPlays(page: Page): Promise { @@ -282,7 +288,8 @@ test('a link carrying the source builds it at once, deflated or not', async ({ p 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)) + // It arrives laid out, a field to a line, where the link carried it on one. + await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS, null, 2)) // 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') @@ -368,22 +375,157 @@ test('a mistake in the JSON is marked as it is typed, with its line and column, expect(problems, problems.join('\n')).toEqual([]) }) -test('Format lays the source out, and takes it out of the code fence a chat put it in', async ({ page, baseURL }) => { +test('a source pasted whole arrives laid out, out of its code fence; any other paste goes in as it is', async ({ page, context, baseURL }) => { const { problems } = watch(page, baseURL) + await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.goto('/h5p/build/') - await fillSource(page, '```json\n' + JSON.stringify(RIVERS) + '\n```') - await page.locator('#format').click() - await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS, null, 2)) - await expect(page.locator('#source-state')).toHaveText('Laid out.') + const field = page.locator('#source') + const lakes = { ...RIVERS, title: 'Lakes' } + await page.locator('.source-editor .cm-content').click() + // Into the empty editor, on one line in a code fence, as a chat may hand it over. + await paste(page, '```json\n' + JSON.stringify(RIVERS) + '\n```') + await expect(field).toHaveValue(JSON.stringify(RIVERS, null, 2)) await expect(page.locator('.source-editor .cm-line').first()).toHaveText('{') + // Over all of the text, too. + await page.keyboard.press('ControlOrMeta+a') + await paste(page, JSON.stringify(lakes)) + await expect(field).toHaveValue(JSON.stringify(lakes, null, 2)) + // Into the text it is the person's own to lay out: it goes in as it is. + await page.keyboard.press('ControlOrMeta+End') + await paste(page, '{"a":1}') + await expect(field).toHaveValue(JSON.stringify(lakes, null, 2) + '{"a":1}') // Text that is not JSON is left as it is, and the line under the source says where. - await fillSource(page, '{ "type": ') - await page.locator('#format').click() + await page.keyboard.press('ControlOrMeta+a') + await paste(page, '{ "type": ') + await expect(field).toHaveValue('{ "type": ') + await expect(page.locator('#source-state')).toContainText('The text ends before the JSON is complete') + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('a source opened from a file arrives laid out, and there is no button to lay it out', async ({ page, baseURL }) => { + const { problems } = watch(page, baseURL) + await page.goto('/h5p/build/') + await expect(page.getByRole('button', { name: 'Format' })).toHaveCount(0) + await page.locator('#source-file').setInputFiles({ name: 'rivers.json', mimeType: 'application/json', buffer: Buffer.from('```json\n' + JSON.stringify(RIVERS) + '\n```') }) + await expect(page.locator('#source')).toHaveValue(JSON.stringify(RIVERS, null, 2)) + await expect(page.locator('.source-editor .cm-line').first()).toHaveText('{') + // A file that is not JSON is as it is, with the mistake marked. + await page.locator('#source-file').setInputFiles({ name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('{ "type": ') }) await expect(page.locator('#source')).toHaveValue('{ "type": ') await expect(page.locator('#source-state')).toContainText('The text ends before the JSON is complete') expect(problems, problems.join('\n')).toEqual([]) }) +/** The source an address carries, as the text of its bytes, plain or deflated: `''` for an empty one, `null` for none or for bytes that are neither. */ +function carriedBy(page: Page): string | null { + const value = new URLSearchParams(new URL(page.url()).hash.slice(1)).get('source') + if (value === null) return null + const bytes = Buffer.from(value, 'base64url') + if (!bytes.length) return '' + const text = bytes.toString('utf8') + try { + JSON.parse(text) + return text + } catch { + try { + return inflateRawSync(bytes).toString('utf8') + } catch { + return null + } + } +} + +/** Counts the page's writes to its address, from before the page's own scripts run. */ +const countAddressWrites = (page: Page) => page.addInitScript(() => { + const counter = window as unknown as { addressWrites: number } + counter.addressWrites = 0 + const replaceState = history.replaceState.bind(history) + history.replaceState = (...args: Parameters) => { + counter.addressWrites++ + return replaceState(...args) + } +}) +const addressWrites = (page: Page) => page.evaluate(() => (window as unknown as { addressWrites: number }).addressWrites) + +test('a link that carried the source follows it as it is changed', async ({ page, baseURL }) => { + const { problems } = watch(page, baseURL) + await countAddressWrites(page) + await page.goto(`/h5p/build/#source=${deflateRawSync(Buffer.from(JSON.stringify(RIVERS))).toString('base64url')}`) + await expectPlays(page) + // Arriving writes nothing: the address already carries the source that is shown. + await page.waitForTimeout(900) + expect(await addressWrites(page)).toBe(0) + const entries = await page.evaluate(() => history.length) + + // Another source: the address carries it, on one line, and the history has no new entry. + const lakes = { ...RIVERS, title: 'Lakes' } + await fillSource(page, JSON.stringify(lakes, null, 2)) + await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(lakes)) + expect(await addressWrites(page)).toBe(1) + expect(await page.evaluate(() => history.length), 'replaced, not added').toBe(entries) + + // A change of layout is not a change of the source, and text that is not JSON leaves the address as it was. + await page.keyboard.press('ControlOrMeta+End') + await page.keyboard.press('Enter') + await page.waitForTimeout(900) + expect(await addressWrites(page)).toBe(1) + await page.keyboard.type('x') + await expect(page.locator('#source-state')).toContainText('Not valid JSON') + await page.waitForTimeout(900) + expect(await addressWrites(page)).toBe(1) + expect(carriedBy(page)).toBe(JSON.stringify(lakes)) + await page.keyboard.press('Backspace') + + // A reload builds what the address carries now. + await page.reload() + await expectPlays(page) + await expect(page.locator('#source')).toHaveValue(JSON.stringify(lakes, null, 2)) + await expect(page.locator('#status')).toContainText('Built “Lakes”') + + // Emptied, it carries none, and the address is still the link of a page that follows: the next source is written. + await page.locator('.source-editor .cm-content').click() + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.press('Delete') + await expect.poll(() => carriedBy(page)).toBe('') + const seas = { ...RIVERS, title: 'Seas' } + await fillSource(page, JSON.stringify(seas)) + await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(seas)) + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('a page opened with no source in its address leaves the address alone', async ({ page, baseURL }) => { + const { problems } = watch(page, baseURL) + await countAddressWrites(page) + await page.goto('/h5p/build/') + await fillSource(page, JSON.stringify(RIVERS)) + await page.waitForTimeout(900) + expect(await addressWrites(page)).toBe(0) + expect(new URL(page.url()).hash).toBe('') + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('a link that cannot be read gives way to the source the person writes', async ({ page, baseURL }) => { + const { problems } = watch(page, baseURL) + await page.goto('/h5p/build/#source=not-a-source') + await expect(page.locator('#status')).toContainText('neither JSON nor deflated JSON') + await fillSource(page, JSON.stringify(RIVERS)) + await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(RIVERS)) + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('where the browser cannot compress, the address carries the JSON as it is', async ({ page, baseURL }) => { + const { problems } = watch(page, baseURL) + await page.addInitScript(() => { Reflect.deleteProperty(window, 'CompressionStream') }) + await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify(RIVERS)).toString('base64url')}`) + await expectPlays(page) + const lakes = { ...RIVERS, title: 'Lakes' } + await fillSource(page, JSON.stringify(lakes, null, 2)) + await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(lakes)) + const value = new URLSearchParams(new URL(page.url()).hash.slice(1)).get('source')! + expect(Buffer.from(value, 'base64url').toString('utf8')).toBe(JSON.stringify(lakes)) + expect(problems, problems.join('\n')).toEqual([]) +}) + test('a problem the build finds is marked where it is in the source, and its name goes there', async ({ page, baseURL }) => { const { problems } = watch(page, baseURL) await page.goto('/h5p/build/') @@ -420,7 +562,7 @@ test('the editor has a name, its label goes to it, and Tab leaves it', async ({ await page.getByText('The source, as JSON', { exact: true }).click() await expect(editor).toBeFocused() await page.keyboard.press('Tab') - await expect(page.locator('#format')).toBeFocused() + await expect(page.locator('#source-file')).toBeFocused() }) test('text typed over a selection is the editor’s own, so a bracket wraps it and the policy is not asked', async ({ page, baseURL }) => { @@ -439,6 +581,27 @@ test('text typed over a selection is the editor’s own, so a bracket wraps it a expect(problems, problems.join('\n')).toEqual([]) }) +test('where the editor cannot load, the textarea lays a whole paste out too, and the address follows it', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.route('**/h5p/assets/editor-*.js', (route) => route.abort()) + await page.goto('/h5p/build/') + const field = page.locator('#source') + await field.click() + await paste(page, '```json\n' + JSON.stringify(RIVERS) + '\n```') + await expect(field).toHaveValue(JSON.stringify(RIVERS, null, 2)) + // Into the text, as it is. + await field.evaluate((area: HTMLTextAreaElement) => area.setSelectionRange(area.value.length, area.value.length)) + await paste(page, '{"a":1}') + await expect(field).toHaveValue(JSON.stringify(RIVERS, null, 2) + '{"a":1}') + // A link that carried the source follows the textarea as it does the editor. + await page.goto(`/h5p/build/#source=${Buffer.from(JSON.stringify(RIVERS)).toString('base64url')}`) + await expectPlays(page) + await expect(field).toHaveValue(JSON.stringify(RIVERS, null, 2)) + const lakes = { ...RIVERS, title: 'Lakes' } + await field.fill(JSON.stringify(lakes)) + await expect.poll(() => carriedBy(page)).toBe(JSON.stringify(lakes)) +}) + test('where the editor cannot load, the source is the textarea, and the page works', async ({ page, baseURL }) => { const { elsewhere } = watch(page, baseURL) await page.route('**/h5p/assets/editor-*.js', (route) => route.abort()) diff --git a/src/assets/site.css b/src/assets/site.css index b97e158..dadba9b 100644 --- a/src/assets/site.css +++ b/src/assets/site.css @@ -586,9 +586,8 @@ tbody th a:hover code { text-decoration: underline; } .build textarea[hidden] { display: none; } .build .field { margin: 1rem 0 0.5rem; } .source-editor { display: block; } -.build .source-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.9rem; margin: 0.6rem 0 0.4rem; } -.build .source-tools .button { font-size: 0.875rem; padding: 0.25rem 0.8rem; } -.build #source-state { margin: 0; } +/* A line of its own, kept when it is empty, so that what is under it does not move as a mistake comes and goes. */ +.build #source-state { margin: 0.5rem 0 0.25rem; min-height: 1.5em; } .build #source-state[data-state="error"] { color: var(--bad); } .build .link-button, .build .problems .jump { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; text-decoration: underline; cursor: pointer; diff --git a/src/h5p/build/build-page.ts b/src/h5p/build/build-page.ts index ebaa0d1..d0647ef 100644 --- a/src/h5p/build/build-page.ts +++ b/src/h5p/build/build-page.ts @@ -2,9 +2,9 @@ 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, formatSource, matchFiles, namedFiles, packageName, parseSource, sized, sourceFromFragment, typesNamed, type Problem, type TypeFile } from './build-source' +import { compactSource, forTheChat, fragmentFromSource, linksSource, matchFiles, namedFiles, packageName, parseSource, sized, sourceFromFragment, typesNamed, type Problem, type TypeFile } from './build-source' import type { SourceEditor } from './editor' -import { jsonProblem, place, type Target } from './json-scan' +import { formatSource, jsonProblem, place, type Target } from './json-scan' /** * The build page, `/h5p/build/`: a source an AI chat wrote with the h5p-author skill becomes a @@ -14,11 +14,12 @@ import { jsonProblem, place, type Target } from './json-scan' * `@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. A source with problems gets - * them listed, and copied for the chat with the skill's type files, which this site serves beside - * the page (`TYPES`). The source is edited in an editor (`editor.ts`), loaded apart, in place of the - * textarea, which stays under it as the field this page reads. The page's markup is `index.njk` - * beside this file, and its styles are the site's own, in `site.css`. + * source carries it in its fragment, `#source=`, which no server sees, and the page keeps that + * fragment in step with the source as the person changes it. A source with problems gets them + * listed, and copied for the chat with the skill's type files, which this site serves beside the + * page (`TYPES`). The source is edited in an editor (`editor.ts`), loaded apart, in place of the + * textarea, which stays under it as the field this page reads, and it arrives laid out. The page's + * markup is `index.njk` beside this file, and its styles are the site's own, in `site.css`. */ type Opened = Awaited> @@ -32,7 +33,6 @@ const $ = (id: string): T => { const sourceField = $('source') const sourceLabel = $('source-label') const sourceState = $('source-state') -const formatButton = $('format') const sourceFile = $('source-file') const filesStep = $('files-step') const fileList = $('file-list') @@ -76,6 +76,12 @@ let shows = 0 let packageUrl: string | null = null /** Counts builds, so that one overtaken by a newer one shows nothing. */ let builds = 0 +/** Whether the address carries a source, which it then keeps in step with the source as it changes. */ +let linked = false +/** The source the address carries, as `compactSource` has it: `''` for none, or none that can be read. */ +let carried = '' +/** Counts the writes of the address, so that one overtaken by a newer change writes nothing. */ +let linkWrites = 0 function setStatus(text: string, state: 'idle' | 'building' | 'ready' | 'error'): void { status.textContent = text @@ -291,7 +297,36 @@ function checkSource(): void { sourceState.replaceChildren(...nodes) } +/** + * Where the address carries a source, it carries the one that is here: written once the source is + * JSON and is another source than the address has, not for a change of layout, and not while the + * text is not JSON, when the address keeps the last that was. It is replaced, not added to the + * history, and the page does not read it again: only a visit to another address does. + */ +async function followInAddress(): Promise { + if (!linked) return + const write = ++linkWrites + const now = compactSource(sourceField.value) + if (now === null || now === carried) return + const fragment = await fragmentFromSource(now) + if (write !== linkWrites) return + try { + const url = new URL(location.href) + url.hash = fragment + history.replaceState(history.state, '', url) + carried = now + } catch { + // Safari allows a hundred of these in thirty seconds: the next change to the source writes it. + } +} + let typing: ReturnType | undefined +let following: ReturnType | undefined +/** The address follows the source once its changes pause, which spares the browser a write for each key. */ +function followLater(): void { + clearTimeout(following) + following = setTimeout(() => void followInAddress(), 600) +} /** The source changed, by typing or by the editor: what it says is looked at once the typing pauses. */ function sourceChanged(): void { clearTimeout(typing) @@ -299,37 +334,37 @@ function sourceChanged(): void { checkSource() updateFiles() }, 300) + followLater() } sourceField.addEventListener('input', sourceChanged) -/** The source replaced, as by a file or a link: in the editor if it is there, and always in the textarea. */ +/** The source replaced, as by a file or a link: laid out, in the editor if it is there, and always in the textarea. */ function setSource(text: string): void { - sourceField.value = text - editor?.set(text) + const arrived = formatSource(text) ?? text + sourceField.value = arrived + editor?.set(arrived) checkSource() updateFiles() + followLater() } +// Where the editor has not loaded, a source pasted whole is laid out here, as the editor does it. +sourceField.addEventListener('paste', (event) => { + const text = event.clipboardData?.getData('text/plain') + const laidOut = text && sourceField.selectionStart === 0 && sourceField.selectionEnd === sourceField.value.length ? formatSource(text) : null + if (laidOut === null) return + event.preventDefault() + sourceField.value = laidOut + sourceField.setSelectionRange(0, 0) + sourceChanged() +}) + sourceFile.addEventListener('change', async () => { const file = sourceFile.files?.[0] if (!file) return setSource(await file.text()) }) -formatButton.addEventListener('click', () => { - const formatted = formatSource(sourceField.value) - if (formatted === null) { - // Nothing to lay out, or not JSON yet: the line under the source says where, and the source shows it. - checkSource() - const problem = jsonProblem(sourceField.value) - if (problem?.at !== undefined) goTo({ at: problem.at }) - else if (!sourceField.value.trim()) sourceState.textContent = 'There is nothing to lay out yet.' - return - } - setSource(formatted) - sourceState.textContent = 'Laid out.' -}) - // The label is for the textarea, which the editor hides: a click on it goes to the editor. sourceLabel.addEventListener('click', () => editor?.focus()) @@ -354,6 +389,9 @@ copyProblems.addEventListener('click', async () => { /** A link's source is built at once, unless it names files, which only the person can choose. */ async function fromLink(): Promise { + // A link of this kind is kept in step with the source from here on, whether or not it can be read. + linked = linksSource(location.hash) + carried = '' let text: string | null try { text = await sourceFromFragment(location.hash) @@ -362,6 +400,7 @@ async function fromLink(): Promise { return } if (text === null) return + carried = compactSource(text) ?? '' setSource(text) const paths = updateFiles() if (paths.length) setStatus('The source names files: choose them, then Build.', 'idle') diff --git a/src/h5p/build/build-source.ts b/src/h5p/build/build-source.ts index 81f830e..a6283ff 100644 --- a/src/h5p/build/build-source.ts +++ b/src/h5p/build/build-source.ts @@ -63,6 +63,43 @@ function isJson(text: string): boolean { } } +/** Whether a fragment has a `source=` at all, whatever it carries: a link of this page's kind. */ +export const linksSource = (hash: string): boolean => new URLSearchParams(hash.replace(/^#/, '')).has('source') + +/** + * The source as a link carries it, with its layout taken out: the JSON of an object, out of the code + * fence a chat may have put it in, on one line; `''` for no text. `null` for text a link cannot + * carry, which is not JSON or not an object. Two texts that are one source have one of these, so it + * tells a change of the source from a change of its layout. + */ +export function compactSource(text: string): string | null { + if (!text.trim()) return '' + const json = unfence(text).text + return isJson(json) ? JSON.stringify(JSON.parse(json)) : null +} + +/** + * The fragment that carries a source, the other way from `sourceFromFragment`: `#source=` and the + * source's JSON, as UTF-8 or compressed with raw deflate, whichever is shorter, and the JSON as it + * is where the browser has no `CompressionStream`. Takes what `compactSource` gives; no source is a + * `#source=` with nothing after it, which `sourceFromFragment` reads as none. + */ +export async function fragmentFromSource(compact: string): Promise { + let bytes: Uint8Array = new TextEncoder().encode(compact) + if (bytes.length) { + try { + const deflated = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw')) + const smaller = new Uint8Array(await new Response(deflated).arrayBuffer()) + if (smaller.length < bytes.length) bytes = smaller + } catch { + // Nothing here compresses: the JSON as it is. + } + } + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return `#source=${btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}` +} + /** * Reads the text as a source, or says where it is not JSON, as a problem the chat can fix. */ @@ -74,15 +111,6 @@ export function parseSource(text: string): { source: unknown } | { problem: Prob return { source: JSON.parse(unfence(text).text) } } -/** - * The source laid out, one field to a line, and out of the code fence a chat may have put it in: - * the same JSON, as the text of an editor that tidies it, or `null` where it is not JSON. - */ -export function formatSource(text: string): string | null { - if (!text.trim() || jsonProblem(text)) return null - return JSON.stringify(JSON.parse(unfence(text).text), null, 2) -} - /** 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()) diff --git a/src/h5p/build/editor.ts b/src/h5p/build/editor.ts index 7d9036a..30a358c 100644 --- a/src/h5p/build/editor.ts +++ b/src/h5p/build/editor.ts @@ -6,11 +6,12 @@ import { type Diagnostic, linter, lintGutter, setDiagnostics } from '@codemirror import { EditorState } from '@codemirror/state' import { drawSelection, EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, placeholder } from '@codemirror/view' import { tags } from '@lezer/highlight' -import { jsonProblem, place, type Target } from './json-scan' +import { formatSource, jsonProblem, place, type Target } from './json-scan' /** * The editor of the build page's source, `/h5p/build/`, in place of its textarea: JSON in colours, - * a mistake in it marked as it is typed, and the problems the build finds marked where they are. + * a mistake in it marked as it is typed, the problems the build finds marked where they are, and a + * source pasted whole laid out as it goes in. * It is CodeMirror 6, loaded apart from the page, so that where it cannot load the textarea stays * as it is and the page works. The textarea stays beside it, hidden, holding the text, so that the * rest of the page reads the source as it always did. @@ -88,6 +89,23 @@ const typeOverSelection = EditorView.domEventHandlers({ } }) +/** + * A source pasted whole, into an empty editor or over all of its text, goes in laid out, and out of + * the code fence a chat put it in, so that the page needs no button for it. A paste into the text, + * or of text that is not JSON, goes in as it is. + */ +const pasteLaidOut = EditorView.domEventHandlers({ + paste(event, view) { + const { from, to } = view.state.selection.main + const text = event.clipboardData?.getData('text/plain') + const laidOut = text && from === 0 && to === view.state.doc.length ? formatSource(text) : null + if (laidOut === null) return false + event.preventDefault() + view.dispatch({ changes: { from, to, insert: laidOut }, selection: { anchor: 0 }, userEvent: 'input.paste', scrollIntoView: true }) + return true + } +}) + /** The mistake in the text, as the editor marks it while the text is typed. */ const mistakes = linter((view): Diagnostic[] => { const text = view.state.doc.toString() @@ -137,6 +155,7 @@ export function mountEditor(textarea: HTMLTextAreaElement, onChange: (text: stri autocorrect: 'off' }), typeOverSelection, + pasteLaidOut, EditorView.updateListener.of((update) => { if (!update.docChanged) return const text = update.state.doc.toString() diff --git a/src/h5p/build/index.njk b/src/h5p/build/index.njk index 75cbed7..1825074 100644 --- a/src/h5p/build/index.njk +++ b/src/h5p/build/index.njk @@ -35,14 +35,13 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' da -

- - -

+

- You can change the source here before you build it. A mistake in the JSON is marked as you - type, and each problem the build finds is marked where it is: choose its name in the list - to go there. + You can change the source here before you build it. A source you paste whole or open is laid + out for you. A mistake in the JSON is marked as you type, and each problem the build finds is + marked where it is: choose its name in the list to go there. If this page was opened from a + link that carried the source, its address follows your changes, so you can copy it to send + the source on.

diff --git a/src/h5p/build/json-scan.ts b/src/h5p/build/json-scan.ts index 95d1274..483cc10 100644 --- a/src/h5p/build/json-scan.ts +++ b/src/h5p/build/json-scan.ts @@ -316,6 +316,16 @@ export function jsonProblem(text: string): { message: string; at?: number } | nu } } +/** + * The source laid out, two spaces and one field to a line, and out of the code fence a chat may have + * put it in: the same JSON, as it is shown when it arrives. `null` for text that is not JSON, and + * for none, which is left as it is. + */ +export function formatSource(text: string): string | null { + if (!text.trim() || jsonProblem(text)) return null + return JSON.stringify(JSON.parse(unfence(text).text), null, 2) +} + /** Where to take the person to in a source: an offset, or what a problem's path points at. */ export type Target = { at: number } | { path: string } From ebaca57cf13e6bd178d3f37927fbc6704a53048b Mon Sep 17 00:00:00 2001 From: Aleksandr Shevchuk Date: Wed, 11 Nov 2026 13:30:06 +0200 Subject: [PATCH 2/3] Document that a source arrives laid out and that a link follows it The editor section loses the Format button and gains the two behaviours that replace it, the source laid out as it arrives and the address kept in step with it. editor.ts and json-scan.ts are linked now that both are on main. --- docs/h5p-build-page.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/h5p-build-page.md b/docs/h5p-build-page.md index 88e106d..6ed116f 100644 --- a/docs/h5p-build-page.md +++ b/docs/h5p-build-page.md @@ -2,18 +2,18 @@ `/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 with [the type files](#the-type-files) that chat lacked. 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. Why it exists, and what was measured to build it this way: [design/author.md](https://github.com/missing-elements/h5p-offline-player/blob/main/design/author.md) of h5p-offline-player, under *Where `npx` cannot install, a page builds*. -The source is not uploaded: the package is built in 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. +The source is not uploaded: the package is built in 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 page opened by such a link keeps the fragment in step with the source as the person changes it, so the address can be copied to send the changed source on ([below](#the-source-editor)). 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](../src/h5p/build/index.njk) | The page's markup, in the site's layout, and its Content-Security-Policy: GitHub Pages cannot send a Content-Security-Policy header for a site, so a `` tag is the only policy it gets | -| [src/h5p/build/build-page.ts](../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](../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/h5p/build/editor.ts` | The source's editor, CodeMirror 6, mounted in a shadow root and loaded apart from the page: see [The source editor](#the-source-editor) | -| `src/h5p/build/json-scan.ts` | What it reads of JSON text with no DOM: where a text stops being JSON and what is wrong there, and where a problem's path points | +| [src/h5p/build/build-page.ts](../src/h5p/build/build-page.ts) | The page's wiring: the build, the player, the download, a link's source and the address that follows it | +| [src/h5p/build/build-source.ts](../src/h5p/build/build-source.ts) | What it does with a source with no DOM: the link's fragment, read and written, the files a source names, the content types it is written in, the text for the chat | +| [src/h5p/build/editor.ts](../src/h5p/build/editor.ts) | The source's editor, CodeMirror 6, mounted in a shadow root and loaded apart from the page: see [The source editor](#the-source-editor) | +| [src/h5p/build/json-scan.ts](../src/h5p/build/json-scan.ts) | What it reads of JSON text with no DOM: where a text stops being JSON and what is wrong there, where a problem's path points, and the source laid out | | [src/assets/site.css](../src/assets/site.css) | Its styles, under *H5P build page* | | [vite.config.ts](../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/](../e2e/), [playwright.config.ts](../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; the source's editor marks a mistake and the problems the build finds, and `json-scan.spec.ts` tests the reading of JSON with no browser; 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 | +| [e2e/](../e2e/), [playwright.config.ts](../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; the source's editor marks a mistake and the problems the build finds, lays a whole paste out, and a link follows the source as it changes; `json-scan.spec.ts` tests the reading of JSON with no browser; 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. @@ -26,7 +26,8 @@ A person who gets a source from a chat is a teacher, not a developer, and a miss - **JSON in colours**, with line numbers, matching brackets and undo. The colours are the site's token colours, custom properties that reach into the editor, so light and dark need no code. - **A mistake in the JSON is marked as it is typed**, and said under the source with its line and column, in words for someone who does not write JSON ("A comma is missing here"), with a button that goes there. `JSON.parse` still decides whether text is JSON. `json-scan.ts` says where, because `JSON.parse` says it differently in each browser and Safari does not say at all. `parseSource` uses it too, so the copy for the chat carries the line and the column. - **The problems the build finds are marked where they are**, and the name of a problem in the list is a button that goes there. A problem's path, `params.chapters[0].metadata.title`, is looked up in the text by `locate`: a name and its value, and for what the source is missing, the object it is missing from. The marks are of the text as it was built, so a change to the text clears them. -- **Format** lays the source out, and takes it out of the code fence a chat may have put it in. +- **A source arrives laid out**, so the page has no Format button. One opened from a link or a file, or pasted over all of the text or into an empty editor, is laid out by `formatSource`, two spaces and a field to a line, and taken out of the code fence a chat may have put it in. A paste into the text goes in as it is, and so does text that is not JSON: the line under the source says where it stops being JSON. Where the editor cannot load, the textarea lays a whole paste out too. +- **A link that carried the source follows it.** A page opened with `#source=` replaces its address when the source changes to another source: `history.replaceState`, so the history gets no entry and no `hashchange` fires, and the page does not read the address back. The address carries `compactSource` of the text, the JSON on one line, compressed with raw deflate where there is a `CompressionStream` and that is shorter (`fragmentFromSource`), which is what `sourceFromFragment` reads. So a change of layout writes nothing, and neither does text that is not JSON or not an object: the address keeps the last source it carried, and a link never carries a broken one. An emptied editor leaves `#source=` with nothing after it, so the page keeps following, and a link that could not be read gives way to the first source written. A page opened with no source in its address writes none. The write waits for the typing to pause, since Safari throws on more than a hundred `replaceState` calls in thirty seconds, and one it refuses is made again at the next change. The editor has to fit the page's policy, which has no `style-src 'unsafe-inline'`: an editor that adds a `