diff --git a/e2e/build.spec.ts b/e2e/build.spec.ts index ec42fbd..d083323 100644 --- a/e2e/build.spec.ts +++ b/e2e/build.spec.ts @@ -51,6 +51,19 @@ const copied = async (page: Page): Promise => { return page.evaluate(() => navigator.clipboard.readText()) } +/** + * Fills the source. It is edited in an editor, which loads apart and takes the textarea's place; + * the textarea stays under it holding the same text, which is how the page reads the source. The + * text is typed over a selection of all of it, made by the editor's own key: a page selection that + * a test makes itself can be taken back by the editor, which keeps its own. + */ +async function fillSource(page: Page, text: string): Promise { + await page.locator('.source-editor .cm-content').click() + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.insertText(text) + await expect(page.locator('#source')).toHaveValue(text) +} + const playerState = (page: Page) => page.locator('h5p-player').evaluate((element: HTMLElement & { state?: string }) => element.state) async function expectPlays(page: Page): Promise { @@ -67,7 +80,7 @@ test('a pasted source builds, plays and downloads', async ({ page, baseURL }) => 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 fillSource(page, '```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, ') @@ -96,7 +109,7 @@ test('a source with problems gets them all listed, ready for the chat', async ({ 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 fillSource(page, 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') @@ -115,7 +128,7 @@ test('a source with problems gets them all listed, ready for the chat', async ({ expect(files).toBe(await typeFile(request, 'H5P.Blanks')) // Text that is not JSON is a problem of its own, said where it is, and names no content type. - await page.locator('#source').fill('{ "type": "H5P.Blanks", ') + await fillSource(page, '{ "type": "H5P.Blanks", ') await page.locator('#build').click() await expect(listed).toHaveCount(1) await expect(listed.first()).toContainText('is not valid JSON') @@ -144,7 +157,7 @@ test('the copy carries the type files of the source’s type and of the librarie } } await page.goto('/h5p/build/') - await page.locator('#source').fill(JSON.stringify(book)) + await fillSource(page, JSON.stringify(book)) await page.locator('#build').click() await expect(page.locator('#status')).toHaveAttribute('data-state', 'error') // The build says what to write, as h5p-author does since 0.7.0. @@ -156,7 +169,7 @@ test('the copy carries the type files of the source’s type and of the librarie expect(files).toBe([await typeFile(request, 'H5P.InteractiveBook'), await typeFile(request, 'H5P.AdvancedText'), await typeFile(request, 'H5P.MultiChoice')].join('\n\n---\n\n')) // A type the pack does not have has no file: its problems are copied alone. - await page.locator('#source').fill(JSON.stringify({ ...book, type: 'H5P.ImageChoice', params: {} })) + await fillSource(page, JSON.stringify({ ...book, type: 'H5P.ImageChoice', params: {} })) await page.locator('#build').click() await expect(page.locator('#problems li')).not.toHaveCount(0) const alone = await copied(page) @@ -173,7 +186,7 @@ test('the copy carries the type file of a type named as it is spoken of, or unde /** The type files a source's copy carries after its problems, once a problem of it is shown. */ const filesOf = async (source: unknown, said: string): Promise => { await page.goto('/h5p/build/') - await page.locator('#source').fill(JSON.stringify(source)) + await fillSource(page, JSON.stringify(source)) await page.locator('#build').click() await expect(page.locator('#problems')).toContainText(said) return (await copied(page)).split(`\n\n${TYPES_HEADING}\n\n`)[1] @@ -209,7 +222,7 @@ test('the problems are copied alone where the type files cannot be fetched, and await page.route('**/h5p/build/types/**', (route) => route.fulfill({ status: 503, body: 'unavailable' })) await page.goto('/h5p/build/') const { title: _title, ...untitled } = RIVERS - await page.locator('#source').fill(JSON.stringify(untitled)) + await fillSource(page, JSON.stringify(untitled)) await page.locator('#build').click() await expect(page.locator('#problems li')).toHaveCount(1) expect(await copied(page)).toMatch(/^The build page found 1 problem in the source\. Fix them all and write the whole source again:\n- title: [^\n]*$/) @@ -231,11 +244,11 @@ test('type files that arrive once newer problems are shown are not copied with t }) await page.goto('/h5p/build/') const { title: _title, ...untitled } = RIVERS - await page.locator('#source').fill(JSON.stringify(untitled)) + await fillSource(page, JSON.stringify(untitled)) await page.locator('#build').click() await expect(page.locator('#problems li code')).toHaveText('title') // Its type file is on its way when the next source's problems replace these: text that names no type. - await page.locator('#source').fill('{ "type": ') + await fillSource(page, '{ "type": ') await page.locator('#build').click() await expect(page.locator('#problems li')).toContainText('is not valid JSON') const arrived = page.waitForResponse('**/h5p/build/types/H5P.Blanks.md') @@ -333,3 +346,111 @@ test('a source that names a file waits for it, then builds with it', async ({ pa 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([]) }) + +// The source's editor: CodeMirror, loaded apart, in place of the textarea, which stays under it. + +test('a mistake in the JSON is marked as it is typed, with its line and column, and goes when it is put right', async ({ page, baseURL }) => { + const { problems } = watch(page, baseURL) + await page.goto('/h5p/build/') + const state = page.locator('#source-state') + await expect(state).toHaveText('') + await fillSource(page, '{\n "type": "H5P.Blanks"\n "title": "Rivers"\n}') + // Said in words, with where, in the line under the source and in the editor itself. + await expect(state).toContainText('Not valid JSON at line 3, column 3: A comma is missing here') + await expect(page.locator('.source-editor .cm-lintRange-error')).toHaveCount(1) + // The button goes where the mistake is. + await state.getByRole('button', { name: 'Show where' }).click() + await expect(page.locator('.source-editor .cm-activeLine')).toContainText('"title": "Rivers"') + // Put right, it goes. + await fillSource(page, JSON.stringify(RIVERS)) + await expect(state).toHaveText('') + await expect(page.locator('.source-editor .cm-lintRange-error')).toHaveCount(0) + 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 }) => { + const { problems } = watch(page, baseURL) + 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.') + await expect(page.locator('.source-editor .cm-line').first()).toHaveText('{') + // 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 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([]) +}) + +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/') + const { title: _title, ...untitled } = RIVERS + await fillSource(page, JSON.stringify({ ...untitled, colour: 'blue' }, null, 2)) + await page.locator('#build').click() + await expect(page.locator('#status')).toHaveAttribute('data-state', 'error') + // The field that is not a field of a source, name and value; the one that is missing, in the object it is missing from. + const marks = page.locator('.source-editor .cm-lintRange-error') + await expect(marks).toHaveCount(2) + await expect(marks.filter({ hasText: '"colour": "blue"' })).toHaveCount(1) + // Each problem's name goes to its place. + await page.locator('#problems button.jump', { hasText: /^colour$/ }).click() + await expect(page.locator('.source-editor .cm-activeLine')).toContainText('"colour": "blue"') + await page.locator('#problems button.jump', { hasText: /^title$/ }).click() + await expect(page.locator('.source-editor .cm-activeLine')).toHaveText('{') + // The marks are of the text as it was built: a change to it clears them. + await page.keyboard.press('ArrowRight') + await page.keyboard.type(' ') + await expect(marks).toHaveCount(0) + // Text that is not JSON is a problem at a place of its own. + await fillSource(page, '{ "type": "H5P.Blanks" "title": 1 }') + await page.locator('#build').click() + await expect(page.locator('#problems li')).toContainText('is not valid JSON at line 1, column 24: A comma is missing here') + await page.locator('#problems button.jump').click() + await expect(page.locator('.source-editor .cm-activeLine')).toContainText('"title": 1') + expect(problems, problems.join('\n')).toEqual([]) +}) + +test('the editor has a name, its label goes to it, and Tab leaves it', async ({ page }) => { + await page.goto('/h5p/build/') + const editor = page.getByRole('textbox', { name: 'The source, as JSON' }) + await expect(editor).toBeVisible() + 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() +}) + +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 }) => { + const { problems } = watch(page, baseURL) + await page.goto('/h5p/build/') + await fillSource(page, JSON.stringify(RIVERS, null, 2)) + await page.locator('.source-editor .cm-content').click() + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.type('[') + await expect(page.locator('#source')).toHaveValue(`[${JSON.stringify(RIVERS, null, 2)}]`) + await page.keyboard.press('ControlOrMeta+a') + await page.keyboard.type('x') + await expect(page.locator('#source')).toHaveValue('x') + await page.keyboard.press('ControlOrMeta+z') + await expect(page.locator('#source')).toHaveValue(`[${JSON.stringify(RIVERS, null, 2)}]`) + expect(problems, problems.join('\n')).toEqual([]) +}) + +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()) + await page.goto('/h5p/build/') + const field = page.locator('#source') + await expect(field).toBeVisible() + await field.fill(JSON.stringify({ ...RIVERS, authors: [] })) + await page.locator('#build').click() + await expect(page.locator('#problems li code')).toHaveText('authors') + // Its name selects where it is, in the textarea. + await page.locator('#problems button.jump').click() + expect(await field.evaluate((area: HTMLTextAreaElement) => area.value.slice(area.selectionStart, area.selectionEnd))).toBe('"authors":[') + await expect(page.locator('.source-editor')).toHaveCount(0) + expect(elsewhere).toEqual([]) +}) diff --git a/e2e/json-scan.spec.ts b/e2e/json-scan.spec.ts new file mode 100644 index 0000000..9e4299a --- /dev/null +++ b/e2e/json-scan.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test' +import { jsonError, lineColumn, locate } from '../src/h5p/build/json-scan' + +/* + * The reading of JSON text behind the build page's editor, with no browser: where a text stops being + * JSON and what is wrong there, and where a problem's path points. The page's own tests (build.spec.ts) + * show both in the editor. + */ + +test('JSON, and text with nothing in it, has no mistake', () => { + for (const text of ['{}', '[]', ' { "a": [1, -2.5e+3, 0, true, false, null, "x\\n\\u00e9\\"", {}] } \n', '"text"', '0', '', ' \n']) { + expect(jsonError(text), JSON.stringify(text)).toBeNull() + } +}) + +test('a mistake is found where it is, and said in words for someone who does not write JSON', () => { + /** The text with a marker, `¦`, at the offset the mistake is expected at, taken out. */ + const cases: [string, RegExp][] = [ + ['{ "a": 1 ¦"b": 2 }', /comma is missing/], + ['{ "a": [1, 2 ¦}', /Expected "," or "\]"/], + ['{ "a": 1¦, }', /comma right before a closing bracket/], + ['[1, 2¦,]', /comma right before a closing bracket/], + ['{ "a": 1¦', /text ends before the JSON is complete/], + ['{ ¦\'a\': 1 }', /not in 'single quotes'/], + ['{ "a": ¦True }', /"True" is not JSON/], + ['{ "a": ¦nope }', /"nope" is not JSON/], + ['{ "a": "line¦\nbreak" }', /line break inside text in quotes/], + ['{ "a": ¦01 }', /not a valid number/], + ['{ "a": ¦1.5.2 }', /not a valid number/], + ['{ "a": 1 } ¦extra', /more text after the end/], + ['{ "a": ¦// note\n 1 }', /no comments/], + ['{ "a" ¦1 }', /Expected ":" after the name/], + ['{ "a": ¦}', /Expected a value here/], + ['{ "a": ¦"x', /not closed/], + ['{ "a": "x¦\\q" }', /A backslash starts an escape/], + ['{ "a": "x¦\\u12" }', /four hexadecimal digits/], + ['{ ¦a: 1 }', /Expected a name in "double quotes" here, but found "a"/], + ['{ "a": 1, ¦, "b": 2 }', /Expected a name in "double quotes" here, but found ","/] + ] + for (const [marked, message] of cases) { + const offset = marked.indexOf('¦') + const text = marked.replace('¦', '') + const error = jsonError(text) + expect(error, text).not.toBeNull() + expect(error!.offset, text).toBe(offset) + expect(error!.message, text).toMatch(message) + } +}) + +test('what is not JSON for JSON.parse is not for this reading either', () => { + for (const text of ['{ "a": 1 "b": 2 }', '{ "a": [1, 2 }', "{ 'a': 1 }", '{ "a": True }', '{ "a": 01 }', '{ "a": 1 } x', '{ "a": 1, }', '[1,]', '{"a" 1}', '{', '[', '"x', '{"a":"\n"}']) { + expect(() => JSON.parse(text), text).toThrow() + expect(jsonError(text), text).not.toBeNull() + } + for (const text of ['{"a":[1,2,{"b":null}]}', '[]', '{"a":"\\u00e9\\n"}', '-0.5E-3', '"
"']) { + expect(() => JSON.parse(text), text).not.toThrow() + expect(jsonError(text), text).toBeNull() + } +}) + +test('a text nested too deeply is a mistake, not a crash', () => { + const error = jsonError('['.repeat(1000)) + expect(error?.message).toMatch(/nested too deeply/) +}) + +test('an offset is a line and a column, each from 1', () => { + const text = '{\n "a": 1\n "b": 2\n}' + const error = jsonError(text) + expect(lineColumn(text, error!.offset)).toEqual({ line: 3, column: 3 }) + expect(lineColumn('', 0)).toEqual({ line: 1, column: 1 }) + expect(lineColumn('abc', 3)).toEqual({ line: 1, column: 4 }) +}) + +test('a problem’s path points at its name and value, or at what it is missing from', () => { + const source = JSON.stringify({ type: 'H5P.Blanks', title: 'Rivers', authors: [], params: { questions: ['a', 'b'] } }, null, 2) + const at = (path: string): { text: string; exact: boolean } | null => { + const found = locate(source, path) + return found && { text: source.slice(found.from, found.to), exact: found.exact } + } + // A scalar whole, with its name; an object or an array by its name and its opening bracket. + expect(at('title')).toEqual({ text: '"title": "Rivers"', exact: true }) + expect(at('params.questions[1]')).toEqual({ text: '"b"', exact: true }) + expect(at('params')).toEqual({ text: '"params": {', exact: true }) + expect(at('authors')).toEqual({ text: '"authors": [', exact: true }) + // What the source has none of is looked for in what it is missing from. + expect(at('colour')).toEqual({ text: '{', exact: false }) + expect(at('params.questions[5]')).toEqual({ text: '"questions": [', exact: false }) + expect(at('params.missing.deep')).toEqual({ text: '"params": {', exact: false }) + expect(at('title.inside')).toEqual({ text: '"title": "Rivers"', exact: false }) + // A path that cannot be read, and a text that is not JSON, have no place. + expect(at('nope[')).toBeNull() + expect(at('')).toBeNull() + expect(at('a..b')).toBeNull() + expect(locate('{ "title": ', 'title')).toBeNull() +}) + +test('a name with an escape in it is found by what it says', () => { + const text = '{ "caf\\u00e9": { "x": 1 } }' + const found = locate(text, 'café.x') + expect(found && text.slice(found.from, found.to)).toBe('"x": 1') +}) diff --git a/package.json b/package.json index aac751e..fcc48dc 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,14 @@ "vite": "8.3.6" }, "dependencies": { + "@codemirror/autocomplete": "6.20.4", + "@codemirror/commands": "6.11.1", + "@codemirror/lang-json": "6.0.2", + "@codemirror/language": "6.13.1", + "@codemirror/lint": "6.9.7", + "@codemirror/state": "6.7.6", + "@codemirror/view": "6.43.15", + "@lezer/highlight": "1.2.5", "@missing-elements/h5p-author": "0.9.2", "@missing-elements/h5p-libraries": "1.1.0", "@missing-elements/h5p-offline-player": "1.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acfda85..ce7049d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,30 @@ importers: .: dependencies: + '@codemirror/autocomplete': + specifier: 6.20.4 + version: 6.20.4 + '@codemirror/commands': + specifier: 6.11.1 + version: 6.11.1 + '@codemirror/lang-json': + specifier: 6.0.2 + version: 6.0.2 + '@codemirror/language': + specifier: 6.13.1 + version: 6.13.1 + '@codemirror/lint': + specifier: 6.9.7 + version: 6.9.7 + '@codemirror/state': + specifier: 6.7.6 + version: 6.7.6 + '@codemirror/view': + specifier: 6.43.15 + version: 6.43.15 + '@lezer/highlight': + specifier: 1.2.5 + version: 1.2.5 '@missing-elements/h5p-author': specifier: 0.9.2 version: 0.9.2(@missing-elements/h5p-runtime@1.0.1) @@ -101,6 +125,45 @@ packages: resolution: {integrity: sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==} engines: {node: '>=18'} + '@codemirror/autocomplete@6.20.4': + resolution: {integrity: sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==} + + '@codemirror/commands@6.11.1': + resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/language@6.13.1': + resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/state@6.7.6': + resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==} + + '@codemirror/streamparser@6.0.0': + resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==} + + '@codemirror/view@6.43.15': + resolution: {integrity: sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==} + + '@lezer/common@1.5.5': + resolution: {integrity: sha512-Pw7PVCndruF1pb3ebCxpSVpkMHSDjwRCLcTRw+RizUKco5QbWylwq2g4lnG/RYN7yTTQDXE9F77MwtkAyphrjg==} + + '@lezer/highlight@1.2.5': + resolution: {integrity: sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.12': + resolution: {integrity: sha512-On9HU4oMMiHPMkQDgoTnGahjgG5n/MkN38CgG77sMraKLL+3JBbeQNi1FcCXV9QRqwF9/BUXP3caHyJC1fBOTQ==} + + '@marijn/find-cluster-break@1.0.4': + resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==} + '@missing-elements/h5p-author@0.9.2': resolution: {integrity: sha512-TWUwkZfLngfmeamNYHAyVHZD7RolCey66LdNzzmvLJZDn8UWRpqqULk2cOUBAcINBxzNIRb6fpSSQ1/kgQlW/w==} engines: {node: '>=22'} @@ -496,6 +559,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -929,6 +995,9 @@ packages: resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} engines: {node: '>=0.10.0'} + style-mod@4.1.4: + resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -998,6 +1067,9 @@ packages: yaml: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + ws@8.22.0: resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} engines: {node: '>=10.0.0'} @@ -1133,6 +1205,72 @@ snapshots: minimatch: 3.1.5 slash: 3.0.0 + '@codemirror/autocomplete@6.20.4': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + '@lezer/common': 1.5.5 + + '@codemirror/commands@6.11.1': + dependencies: + '@codemirror/language': 6.13.1 + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + '@lezer/common': 1.5.5 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.13.1 + '@lezer/json': 1.0.3 + + '@codemirror/language@6.13.1': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/streamparser': 6.0.0 + '@codemirror/view': 6.43.15 + '@lezer/common': 1.5.5 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.12 + style-mod: 4.1.4 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.6 + '@codemirror/view': 6.43.15 + crelt: 1.0.7 + + '@codemirror/state@6.7.6': + dependencies: + '@marijn/find-cluster-break': 1.0.4 + + '@codemirror/streamparser@6.0.0': {} + + '@codemirror/view@6.43.15': + dependencies: + '@codemirror/state': 6.7.6 + crelt: 1.0.7 + style-mod: 4.1.4 + w3c-keyname: 2.2.8 + + '@lezer/common@1.5.5': {} + + '@lezer/highlight@1.2.5': + dependencies: + '@lezer/common': 1.5.5 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.5 + '@lezer/highlight': 1.2.5 + '@lezer/lr': 1.4.12 + + '@lezer/lr@1.4.12': + dependencies: + '@lezer/common': 1.5.5 + + '@marijn/find-cluster-break@1.0.4': {} + '@missing-elements/h5p-author@0.9.2(@missing-elements/h5p-runtime@1.0.1)': dependencies: '@missing-elements/h5p-libraries': 1.1.0 @@ -1378,6 +1516,8 @@ snapshots: concat-map@0.0.1: {} + crelt@1.0.7: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -1736,6 +1876,8 @@ snapshots: strip-bom-string@1.0.0: {} + style-mod@4.1.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -1785,4 +1927,6 @@ snapshots: '@types/node': 26.6.6 fsevents: 2.3.3 + w3c-keyname@2.2.8: {} + ws@8.22.0: {} diff --git a/src/assets/site.css b/src/assets/site.css index e286755..b97e158 100644 --- a/src/assets/site.css +++ b/src/assets/site.css @@ -582,6 +582,18 @@ tbody th a:hover code { text-decoration: underline; } font: 0.8125rem/1.6 var(--mono); } .build textarea:hover { border-color: var(--text); } +/* The source's editor hides its textarea, which stays under it as the field the page reads. */ +.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; } +.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; +} +.build .problems .jump code { color: inherit; } .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; diff --git a/src/h5p/build/build-page.ts b/src/h5p/build/build-page.ts index 3023323..ebaa0d1 100644 --- a/src/h5p/build/build-page.ts +++ b/src/h5p/build/build-page.ts @@ -2,7 +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, matchFiles, namedFiles, packageName, parseSource, sized, sourceFromFragment, typesNamed, type Problem, type TypeFile } from './build-source' +import { forTheChat, formatSource, 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' /** * The build page, `/h5p/build/`: a source an AI chat wrote with the h5p-author skill becomes a @@ -14,8 +16,9 @@ import { forTheChat, matchFiles, namedFiles, packageName, parseSource, sized, so * 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 page's markup is `index.njk` beside this file, and its styles are the - * site's own, in `site.css`. + * 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`. */ type Opened = Awaited> @@ -27,6 +30,9 @@ 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') @@ -48,6 +54,8 @@ const player = $('player') as HTMLElementTagNameMap['h5p-player'] player.runtime = runtime +/** The editor the source is in, once it has loaded; until then, and where it cannot, the textarea. */ +let editor: SourceEditor | null = null /** 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. */ @@ -74,12 +82,38 @@ function setStatus(text: string, state: 'idle' | 'building' | 'ready' | 'error') status.dataset.state = state } -function fillList(list: HTMLUListElement, items: Problem[]): void { - list.replaceChildren(...items.map(({ path, message }) => { +/** Where a problem is in the source, for the button that goes there: its place in the text, or none. */ +function target({ path, at }: Problem): Target { + return at === undefined ? { path } : { at } +} + +/** Selects where a target is in the source, in the editor or, where it has not loaded, the textarea. */ +function goTo(where: Target): boolean { + if (editor) return editor.goTo(where) + const range = place(sourceField.value, where) + if (!range) return false + sourceField.focus() + sourceField.setSelectionRange(range.from, range.to) + return true +} + +/** The list's items, each problem's path a button that goes where it is in the source, where it is anywhere. */ +function fillList(list: HTMLUListElement, items: Problem[], jump = false): void { + list.replaceChildren(...items.map((problem) => { const item = document.createElement('li') const where = document.createElement('code') - where.textContent = path - item.append(where, ` ${message}`) + where.textContent = problem.path + if (jump && place(sourceField.value, target(problem))) { + const button = document.createElement('button') + button.type = 'button' + button.className = 'jump' + button.title = 'Show where it is in the source' + button.append(where) + button.addEventListener('click', () => goTo(target(problem))) + item.append(button, ` ${problem.message}`) + } else { + item.append(where, ` ${problem.message}`) + } return item })) } @@ -122,7 +156,8 @@ function showProblems(problems: Problem[], source?: unknown): void { const show = ++shows shownProblems = problems shownTypeFiles = [] - fillList(problemsList, problems) + fillList(problemsList, problems, true) + editor?.mark(problems) problemsBox.hidden = problems.length === 0 copyResult.textContent = '' copyTypes.hidden = true @@ -235,19 +270,69 @@ player.addEventListener('error', (event) => { setStatus(`It was built, but does not play: ${message}`, 'error') }) +/** Says where the source stops being JSON, if it does, as it is typed, and what it names. */ +function checkSource(): void { + const problem = jsonProblem(sourceField.value) + sourceState.dataset.state = problem ? 'error' : '' + if (!problem) { + sourceState.replaceChildren() + return + } + const nodes: (string | HTMLElement)[] = [`Not valid JSON ${problem.message}`] + if (problem.at !== undefined) { + const at = problem.at + const show = document.createElement('button') + show.type = 'button' + show.className = 'link-button' + show.textContent = 'Show where' + show.addEventListener('click', () => goTo({ at })) + nodes.push(' ', show) + } + sourceState.replaceChildren(...nodes) +} + let typing: ReturnType | undefined -sourceField.addEventListener('input', () => { +/** The source changed, by typing or by the editor: what it says is looked at once the typing pauses. */ +function sourceChanged(): void { clearTimeout(typing) - typing = setTimeout(updateFiles, 300) -}) + typing = setTimeout(() => { + checkSource() + updateFiles() + }, 300) +} +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. */ +function setSource(text: string): void { + sourceField.value = text + editor?.set(text) + checkSource() + updateFiles() +} sourceFile.addEventListener('change', async () => { const file = sourceFile.files?.[0] if (!file) return - sourceField.value = await file.text() - updateFiles() + 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()) + filesInput.addEventListener('change', () => { for (const file of filesInput.files ?? []) chosen.set(file.name, file) filesInput.value = '' @@ -277,7 +362,7 @@ async function fromLink(): Promise { return } if (text === null) return - sourceField.value = text + setSource(text) const paths = updateFiles() if (paths.length) setStatus('The source names files: choose them, then Build.', 'idle') else await build() @@ -286,3 +371,10 @@ async function fromLink(): Promise { updateFiles() void fromLink() window.addEventListener('hashchange', () => void fromLink()) + +// The editor is loaded apart: where it cannot be, the textarea is the source, as it was. +import('./editor').then(({ mountEditor }) => { + editor = mountEditor(sourceField, sourceChanged) +}).catch((error: unknown) => { + console.warn('The source editor did not load; the source stays a plain text field.', error) +}) diff --git a/src/h5p/build/build-source.ts b/src/h5p/build/build-source.ts index 3fef3ad..81f830e 100644 --- a/src/h5p/build/build-source.ts +++ b/src/h5p/build/build-source.ts @@ -5,11 +5,17 @@ */ import { sourceType } from '@missing-elements/h5p-author/browser' +import { jsonProblem, unfence } from './json-scan' -/** A problem or a note, as the author package reports them: where in the source, and what. */ +/** + * A problem or a note, as the author package reports them: where in the source, and what. A + * problem with the text itself, which is not JSON, has no path in the source, and says where in + * the text it is: `at`, an offset. + */ export interface Problem { path: string message: string + at?: number } /** A content type's file of the h5p-author skill: its notes, an example and its fields. */ @@ -62,13 +68,19 @@ function isJson(text: string): boolean { */ 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' } } + const problem = jsonProblem(text) + if (problem) return { problem: { path: 'source', message: `is not valid JSON ${problem.message}`, at: problem.at } } // 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}` } } - } + 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. */ diff --git a/src/h5p/build/editor.ts b/src/h5p/build/editor.ts new file mode 100644 index 0000000..7d9036a --- /dev/null +++ b/src/h5p/build/editor.ts @@ -0,0 +1,178 @@ +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete' +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' +import { json } from '@codemirror/lang-json' +import { bracketMatching, HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language' +import { type Diagnostic, linter, lintGutter, setDiagnostics } from '@codemirror/lint' +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' + +/** + * 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. + * 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. + * + * It is mounted in a shadow root. The page's policy has no `style-src 'unsafe-inline'`, and an + * editor that adds a `