From c8bbbfe4eec643dea36c709e489ef4f077e47fe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:10:40 +0000 Subject: [PATCH 01/11] build(idevices): reuse centralized TypeScript build convention Selectively apply the generic TypeScript-iDevice build infrastructure from upstream PR exelearning/exelearning#2147 (commits 085d7f9 and f29923e), without any Interactive Video code: - scripts/build-idevices.ts + scripts/build-idevices.spec.ts: centralized, convention-based builder (src/edition|export/index.ts -> classic IIFE bundles, per-iDevice strict tsconfig typecheck, --only/--watch/--typecheck, optional build.config.json manifest for deviations). - Slide migrates onto the central builder via build.config.json; the bespoke scripts/build-slide-editor.ts is removed and package.json gains typecheck:idevices / bundle:idevices / bundle:idevices:watch, wired into build:all before resource packaging. - vitest.config.mts discovers TypeScript-iDevice specs and coverage by convention (public/files/perm/idevices/base/*/src/**) instead of naming a single iDevice. - build-resource-bundles.js keeps .map files out of resource ZIPs. - Generic E2E helpers from the upstream infra commit (downloadViaFileMenu, extractZipToTempDir) and the e2e.yml artifact-list comment. - Docs: ADR-0006 (TypeScript iDevices build convention), doc/development/idevices-typescript.md, records/mkdocs/AGENTS/skill rows. ADR-0001..0005 IDs stay reserved for the upstream Interactive Video PR. Co-Authored-By: Claude --- .agents/skills/idevice/SKILL.md | 19 ++ .github/workflows/e2e.yml | 3 + .gitignore | 2 +- AGENTS.md | 3 + ...06-typescript-idevices-build-convention.md | 124 +++++++ doc/development/idevices-typescript.md | 97 ++++++ mkdocs.yml | 1 + package.json | 6 +- .../idevices/base/slide/build.config.json | 16 + scripts/build-idevices.spec.ts | 130 ++++++++ scripts/build-idevices.ts | 311 ++++++++++++++++++ scripts/build-resource-bundles.js | 3 + scripts/build-resource-bundles.spec.ts | 21 ++ scripts/build-slide-editor.ts | 79 ----- .../playwright/helpers/workarea-helpers.ts | 69 +++- vitest.config.mts | 30 +- 16 files changed, 814 insertions(+), 100 deletions(-) create mode 100644 doc/architecture/adr/ADR-0006-typescript-idevices-build-convention.md create mode 100644 doc/development/idevices-typescript.md create mode 100644 public/files/perm/idevices/base/slide/build.config.json create mode 100644 scripts/build-idevices.spec.ts create mode 100644 scripts/build-idevices.ts delete mode 100644 scripts/build-slide-editor.ts diff --git a/.agents/skills/idevice/SKILL.md b/.agents/skills/idevice/SKILL.md index 4f52c634d8..93992aff37 100644 --- a/.agents/skills/idevice/SKILL.md +++ b/.agents/skills/idevice/SKILL.md @@ -23,6 +23,25 @@ Creating or modifying interactive devices (iDevices) in `public/files/perm/idevi **Reference iDevices** (well-tested, good to study): `checklist`, `rubric`, `geogebra-activity` +## TypeScript iDevices (`src/`) + +An iDevice with a `src/` directory is a **TypeScript iDevice**: its +`edition/.js` and `export/.js` are GENERATED bundles (gitignored) +— never edit them; edit `src/` and rebuild. Convention and commands: + +- `src/edition/index.ts` → `edition/.js` (assigns `window.$exeDevice`); + `src/export/index.ts` → `export/.js` (assigns the runtime global). +- Build/typecheck: `bun run bundle:idevices` / `bun run typecheck:idevices` + (central runner `scripts/build-idevices.ts`; `--only `, `--watch`). + Run `make bundle` after src/ edits and BEFORE E2E, or the preview serves the + stale bundle from `public/bundles/idevices.zip`. +- Tests are colocated `*.spec.ts` (Vitest — `bun test` ignores `public/**`), + plus bundle-contract smoke tests over the compiled IIFEs. +- Deviations (custom bundle name, externals, minify) go in an optional + `build.config.json` — see `doc/development/idevices-typescript.md` and + ADR-0006. Reference implementations: `three-sixty-viewer` (full convention), + `slide` (manifest). + ## Structure ``` diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a3a81df760..cd79ede7e4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -47,6 +47,9 @@ jobs: - name: Build all assets once run: bun run build:static + # The path list must include every GENERATED (gitignored) file the + # workarea serves — the test runners get a fresh checkout, so anything + # missing here 404s at runtime (e.g. TypeScript-iDevice bundles, ADR-0006). - name: Upload dynamic bundles (chromium/firefox) uses: actions/upload-artifact@v7 with: diff --git a/.gitignore b/.gitignore index 279e544a7a..2ede3d4501 100644 --- a/.gitignore +++ b/.gitignore @@ -131,7 +131,7 @@ public/app/dist/ /app/dist/ /app/node_modules/ -# Slide iDevice — pre-built editor bundle (regenerated by package.json postinstall) +# TypeScript iDevice bundles — generated from each iDevice's src/ by scripts/build-idevices.ts /public/files/perm/idevices/base/slide/edition/slide-editor.bundle.js .omc/ diff --git a/AGENTS.md b/AGENTS.md index 2ec4f574e9..1a636697cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ These apply to every skill below. If you cannot meet them, stop and ask the user | `src/**/*.ts` | Backend | `bun test` | `make fix` | | `public/app/**/*.js` | Frontend | `vitest` | `make fix` | | `public/files/perm/idevices/**` | iDevice | `vitest` | `make fix` | +| `public/files/perm/idevices/*/src/**` | iDevice (TypeScript, see [ADR-0006](doc/architecture/adr/ADR-0006-typescript-idevices-build-convention.md)) | `vitest` (`*.spec.ts`) | `make fix` | | `src/shared/export/**` | Exporter | `bun test` | `make fix` | | `src/db/migrations/**` | Database | `bun test` | `make fix` | | `src/routes/api/v1/**` | API v1 | `bun test` | `make fix` | @@ -91,6 +92,7 @@ make up # Docker dev environment | `public/app/**/*.test.js` | Frontend unit tests | `vitest` | | `public/libs/**/*.test.js` | Frontend lib tests | `vitest` | | `public/files/perm/idevices/**/*.test.js` | iDevice tests | `vitest` | +| `public/files/perm/idevices/*/src/**/*.spec.ts` | TypeScript iDevice tests | `vitest` | | `test/e2e/playwright/specs/*.spec.ts` | E2E tests | `playwright` | ### 5.2 Running Individual Tests @@ -352,6 +354,7 @@ Domain-specific guidance lives in `.agents/skills/*/SKILL.md`. | REST API v1 | [doc/development/rest-api.md](doc/development/rest-api.md) | | Embedding in LMS | [doc/development/embedding.md](doc/development/embedding.md) | | Profiling | [doc/development/profiling.md](doc/development/profiling.md) | +| TypeScript iDevices | [doc/development/idevices-typescript.md](doc/development/idevices-typescript.md) | | Styles/Themes | [doc/development/styles.md](doc/development/styles.md) | | Conventions | [doc/conventions.md](doc/conventions.md) | | Architecture | [doc/architecture.md](doc/architecture.md) | diff --git a/doc/architecture/adr/ADR-0006-typescript-idevices-build-convention.md b/doc/architecture/adr/ADR-0006-typescript-idevices-build-convention.md new file mode 100644 index 0000000000..a76bea3cd6 --- /dev/null +++ b/doc/architecture/adr/ADR-0006-typescript-idevices-build-convention.md @@ -0,0 +1,124 @@ +--- +id: ADR-0006 +title: "TypeScript iDevices: src/ sources compiled by one convention-based build" +status: Proposed +date: 2026-07-30 +deciders: + - "@erseco" +reviewers: + - "@mnunezcedec" + - "@cristinavaldera" +related: + issues: [] + prs: [2147] + sdds: [SDD-0001] + adrs: [ADR-0005] +supersedes: [] +superseded_by: [] +ai_assistance: + tool: "Claude Code" + model: "claude-fable-5" +--- + +# ADR-0006: TypeScript iDevices — `src/` sources compiled by one convention-based build + +## Status + +Proposed + +## Context + +iDevices are classic-script objects loaded by the workarea and the exporters. +Historically each one is hand-written vanilla JavaScript committed directly +under `edition/` and `export/`. Two iDevices now keep their maintained source +in TypeScript instead — Slide (`src/` + a bespoke `scripts/build-slide-editor.ts`) +and, with this refactor, the 360° Viewer. Per-iDevice build scripts duplicate +Bun plumbing and diverge in flags and behaviour, and every future TypeScript +iDevice would have added another copy plus more package.json entries. + +## Problem + +How does the repository recognise, build, type-check and test an iDevice whose +maintained source is TypeScript, without a new build pipeline per iDevice? + +## Decision drivers + +- One obvious convention for the next TypeScript iDevice (zero new scripts). +- The shipped output must remain plain classic-script IIFEs (ADR-0005: the + language and compile step are not a framework). +- Generated artifacts must never be committed; a clean checkout must + regenerate them through the existing pipeline (`build:all` / `make bundle`). +- Existing iDevices with special needs (Slide) must fit without renaming their + shipped bundles. + +## Decision + +**An iDevice that keeps a `src/` directory is a TypeScript iDevice**, built by +the centralized `scripts/build-idevices.ts`: + +- **Convention:** `src/edition/index.ts` → `edition/.js` and + `src/export/index.ts` → `export/.js` — self-contained IIFEs + (`target: browser`, linked source maps, unminified), whose entry points + explicitly assign their window globals (`$exeDevice`, `$`). +- **Escape hatch:** an optional `build.config.json` next to `config.xml` + replaces the convention for that iDevice (custom entries/naming/globalName/ + minify/sourcemap, plus `externals` mapping bare imports to page-provided + globals so vendored libraries are never inlined). Slide uses it. +- **Type checking:** each TypeScript iDevice ships its own `tsconfig.json` + (strict for new code); the runner executes `tsc -p` for every one it finds. +- **Tests:** colocated `*.spec.ts` next to each module, run by **Vitest** + (`bun test` ignores `public/**`), plus bundle-contract smoke tests that + evaluate the compiled IIFEs. +- **Artifacts:** generated bundles and source maps are gitignored; + `build:all` runs `typecheck:idevices` + `bundle:idevices` before + `bundle:resources` (export bundles ship inside `idevices.zip`). + +Package scripts: `typecheck:idevices`, `bundle:idevices`, +`bundle:idevices:watch`; the runner accepts `--only ` and `--watch`. + +## Options considered + +### Option 1: One bespoke build script per TypeScript iDevice (status quo) + +Pros: each script is trivially readable. Cons: duplicated plumbing, per-iDevice +package.json entries, drift between scripts (they already differed in +sourcemaps, watch support and failure reporting). + +### Option 2: Convention-based central runner + per-iDevice manifest (chosen) + +Pros: the next TypeScript iDevice needs no build changes at all; one place to +fix bundler behaviour; deviations are declared, not programmed. Cons: one more +convention to know; the manifest is a small new format (documented in the +runner header and `doc/development/idevices-typescript.md`). + +## Consequences + +### Positive + +- Adding a TypeScript iDevice = create `src/edition|export/index.ts` (+ a + strict `tsconfig.json`); building, type-checking and watching come for free. +- Slide and the 360° Viewer share one build path; Slide's output stayed + byte-identical apart from the generic externals shim's message strings. + +### Negative + +- A hidden convention: `src/` now has meaning. Mitigated by this ADR, + `doc/development/idevices-typescript.md` and the idevice skill. + +### Neutral + +- Classic-script iDevices are untouched; nothing forces a migration. + +## Validation + +- `scripts/build-idevices.spec.ts` covers discovery, the convention, the + manifest and its validation against the real repository state. +- `bun run build:all` exercises typecheck + build for every TypeScript + iDevice on every bundle/test target. + +## References + +- `scripts/build-idevices.ts` (runner; manifest schema in its header). +- `doc/development/idevices-typescript.md` (developer guide). +- PR [#2147](https://github.com/exelearning/exelearning/pull/2147), which + introduced this convention upstream alongside the Interactive Video refactor. diff --git a/doc/development/idevices-typescript.md b/doc/development/idevices-typescript.md new file mode 100644 index 0000000000..50a69e71c3 --- /dev/null +++ b/doc/development/idevices-typescript.md @@ -0,0 +1,97 @@ +# TypeScript iDevices + +Most iDevices are classic-script vanilla JavaScript committed directly under +`edition/` and `export/`. An iDevice whose maintained source lives in a +**`src/` directory is a TypeScript iDevice**: its shipped `edition/*.js` / +`export/*.js` files are **generated bundles** (gitignored — never edit or +commit them) compiled by the centralized build. Slide and the 360° Viewer +follow this model today. The decision record is +[ADR-0006](../architecture/adr/ADR-0006-typescript-idevices-build-convention.md). + +## The convention + +```text +public/files/perm/idevices/base// +├── config.xml # loads the GENERATED bundles by filename +├── tsconfig.json # strict, per-iDevice (noEmit; the bundler emits) +├── build.config.json # OPTIONAL — only when deviating from the convention +├── src/ +│ ├── edition/index.ts # → edition/.js (window.$exeDevice) +│ ├── export/index.ts # → export/.js (window.$) +│ └── **/*.spec.ts # colocated unit tests (Vitest) +├── edition/.js # generated IIFE + .map (gitignored) +└── export/.js # generated IIFE + .map (gitignored) +``` + +`scripts/build-idevices.ts` discovers every iDevice with a `src/` directory +and builds each existing `src/edition/index.ts` / `src/export/index.ts` into a +self-contained classic-script IIFE (browser target, linked source maps, +unminified). Entry points must assign their window globals explicitly: + +```ts +const device = createMyIdeviceEditionDevice(); +(globalThis as { $exeDevice?: unknown }).$exeDevice = device; +``` + +## Commands + +```bash +bun run typecheck:idevices # tsc -p for every per-iDevice tsconfig +bun run bundle:idevices # build every TypeScript iDevice +bun run bundle:idevices:watch # rebuild on src/ changes +bun scripts/build-idevices.ts --only # filter one iDevice +``` + +`build:all` (and therefore `make bundle` and every test target) runs the +typecheck and the build before `bundle:resources`, because export bundles ship +inside `public/bundles/idevices.zip`. **After editing `src/`, run +`make bundle` (or `bundle:idevices` + `bundle:resources`) before E2E tests**, +or the service-worker preview will serve the stale bundle from the zip. + +## Deviating from the convention + +An iDevice with special needs declares a `build.config.json` next to its +`config.xml`; it replaces the convention for that iDevice. Slide's, for +example, keeps its historical bundle name, IIFE global, minified output and +page-provided libraries: + +```json +{ + "entries": [ + { + "entry": "src/index.ts", + "outdir": "edition", + "naming": "[dir]/slide-editor.bundle.[ext]", + "globalName": "__slideEditorInit", + "minify": true, + "sourcemap": "none", + "externals": { + "fabric": "fabric", + "dompurify": { "global": "DOMPurify", "default": true } + } + } + ] +} +``` + +`externals` maps a bare import to a `window` global (vendored under +`public/libs/`) so the library is never inlined; `"default": true` also +exposes it as the module's default export. + +## Testing + +- Unit tests are **colocated `*.spec.ts`** files next to each module, run by + **Vitest** (`bun test` deliberately ignores `public/**`). Add the iDevice's + `src/**/*.spec.ts` glob to `vitest.config.mts` `include` when creating a new + TypeScript iDevice. +- Add **bundle-contract smoke tests** that evaluate the ACTUAL compiled IIFEs + and assert the window globals and their public methods — they catch bundling + problems source-level imports cannot (see + `three-sixty-viewer/src/test/bundle-contract.spec.ts`). +- Playwright coverage works on the built bundles like for any other iDevice. + +## Debugging + +Bundles ship `.js.map` source maps (excluded from resource ZIPs), so browser +stack traces map back to the TypeScript sources; use +`bundle:idevices:watch` while developing. diff --git a/mkdocs.yml b/mkdocs.yml index 65582a32e8..9e08f68ca6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,7 @@ nav: - Real Time: development/real-time.md - Embedding: development/embedding.md - Profiling: development/profiling.md + - TypeScript iDevices: development/idevices-typescript.md - Customization: development/customization.md - Styles: development/styles.md - Installers: development/installers.md diff --git a/package.json b/package.json index d7e91c04e9..b15e634e8c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "dev:local": "bun run --parallel start:local sass:watch", "build": "bun build src/index.ts --outdir dist --target bun --external kysely --external 'kysely/*' --external jsdom && bun build src/cli/index.ts --outfile dist/cli.js --target bun --external kysely --external 'kysely/*' --external jsdom", "build:standalone": "bun scripts/build-standalone.js", - "build:all": "bun run build && bun run --parallel css:node bundle:resources bundle:i18n bundle:app bundle:importers bundle:exporters bundle:slide-editor", + "build:all": "bun run build && bun run typecheck:idevices && bun run bundle:idevices && bun run --parallel css:node bundle:resources bundle:i18n bundle:app bundle:importers bundle:exporters", "bundle:i18n": "bun scripts/build-i18n-bundles.js", "generate:truefalse-messages": "bun scripts/generate-truefalse-messages.ts", "build:static": "bun run build:all && bun scripts/build-static-bundle.ts", @@ -23,7 +23,9 @@ "bundle:importers": "bun scripts/build-importers-bundle.js", "bundle:exporters": "bun scripts/build-exporters-bundle.js", "bundle:resources": "bun scripts/build-resource-bundles.js", - "bundle:slide-editor": "bun scripts/build-slide-editor.ts", + "typecheck:idevices": "bun scripts/build-idevices.ts --typecheck-only", + "bundle:idevices": "bun scripts/build-idevices.ts", + "bundle:idevices:watch": "bun scripts/build-idevices.ts --watch", "icons:generate": "node scripts/generate-material-icons.js", "upload:bundles": "bun scripts/upload-bundle-analysis.js", "predev": "bun scripts/setup-local.js", diff --git a/public/files/perm/idevices/base/slide/build.config.json b/public/files/perm/idevices/base/slide/build.config.json new file mode 100644 index 0000000000..f3f917b778 --- /dev/null +++ b/public/files/perm/idevices/base/slide/build.config.json @@ -0,0 +1,16 @@ +{ + "entries": [ + { + "entry": "src/index.ts", + "outdir": "edition", + "naming": "[dir]/slide-editor.bundle.[ext]", + "globalName": "__slideEditorInit", + "minify": true, + "sourcemap": "none", + "externals": { + "fabric": "fabric", + "dompurify": { "global": "DOMPurify", "default": true } + } + } + ] +} diff --git a/scripts/build-idevices.spec.ts b/scripts/build-idevices.spec.ts new file mode 100644 index 0000000000..0b4dec2d53 --- /dev/null +++ b/scripts/build-idevices.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { discoverTsIdevices, IDEVICES_BASE, resolveEntries } from './build-idevices'; + +function makeIdevice(base: string, name: string, files: Record): string { + const dir = join(base, name); + for (const [path, content] of Object.entries(files)) { + const full = join(dir, path); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, content); + } + return dir; +} + +describe('discoverTsIdevices', () => { + it('finds the real TypeScript iDevices of the repo', () => { + const names = discoverTsIdevices().map(i => i.name); + expect(names).toContain('slide'); + // Classic-script iDevices without src/ are not build candidates. + expect(names).not.toContain('text'); + expect(names).not.toContain('trueorfalse'); + }); + + it('honours the --only filter and skips src-less directories', () => { + const only = discoverTsIdevices(IDEVICES_BASE, ['slide']); + expect(only.map(i => i.name)).toEqual(['slide']); + expect(discoverTsIdevices(IDEVICES_BASE, ['no-such-idevice'])).toEqual([]); + }); + + it('records the per-iDevice tsconfig when one exists', () => { + const byName = new Map(discoverTsIdevices().map(i => [i.name, i])); + expect(byName.get('slide')?.tsconfig).toBeNull(); + }); +}); + +describe('resolveEntries', () => { + it('builds edition and export by convention from src//index.ts', () => { + const base = mkdtempSync(join(tmpdir(), 'idevice-build-')); + try { + const dir = makeIdevice(base, 'demo', { + 'src/edition/index.ts': '', + 'src/export/index.ts': '', + }); + const entries = resolveEntries('demo', dir); + expect(entries.map(e => e.label)).toEqual(['demo/edition', 'demo/export']); + expect(entries[0]).toMatchObject({ + naming: '[dir]/demo.[ext]', + minify: false, + sourcemap: 'linked', + externals: {}, + }); + expect(entries[0]?.outdir.endsWith('/edition')).toBe(true); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('only emits the surfaces that exist', () => { + const base = mkdtempSync(join(tmpdir(), 'idevice-build-')); + try { + const dir = makeIdevice(base, 'demo', { 'src/edition/index.ts': '' }); + expect(resolveEntries('demo', dir).map(e => e.label)).toEqual(['demo/edition']); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('lets a build.config.json replace the convention (the slide shape)', () => { + const base = mkdtempSync(join(tmpdir(), 'idevice-build-')); + try { + const dir = makeIdevice(base, 'demo', { + 'src/index.ts': '', + 'build.config.json': JSON.stringify({ + entries: [ + { + entry: 'src/index.ts', + outdir: 'edition', + naming: '[dir]/demo.bundle.[ext]', + globalName: '__demoInit', + minify: true, + sourcemap: 'none', + externals: { + fabric: 'fabric', + dompurify: { global: 'DOMPurify', default: true }, + }, + }, + ], + }), + }); + const [entry] = resolveEntries('demo', dir); + expect(entry).toMatchObject({ + naming: '[dir]/demo.bundle.[ext]', + globalName: '__demoInit', + minify: true, + sourcemap: 'none', + externals: { + fabric: { global: 'fabric', default: false }, + dompurify: { global: 'DOMPurify', default: true }, + }, + }); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('rejects manifest entries without entry/outdir', () => { + const base = mkdtempSync(join(tmpdir(), 'idevice-build-')); + try { + const dir = makeIdevice(base, 'demo', { + 'src/index.ts': '', + 'build.config.json': JSON.stringify({ entries: [{ outdir: 'edition' }] }), + }); + expect(() => resolveEntries('demo', dir)).toThrow(/entry/); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + + it('matches the repo state: slide builds via its manifest', () => { + const slide = resolveEntries('slide', join(IDEVICES_BASE, 'slide')); + expect(slide).toHaveLength(1); + expect(slide[0]).toMatchObject({ + naming: '[dir]/slide-editor.bundle.[ext]', + globalName: '__slideEditorInit', + minify: true, + }); + }); +}); diff --git a/scripts/build-idevices.ts b/scripts/build-idevices.ts new file mode 100644 index 0000000000..39f2a90d02 --- /dev/null +++ b/scripts/build-idevices.ts @@ -0,0 +1,311 @@ +/** + * Centralized build for TypeScript-based iDevices. + * + * Any iDevice under `public/files/perm/idevices/base//` that keeps its + * maintained source in a `src/` directory is built by CONVENTION: + * + * src/edition/index.ts -> edition/.js + * src/export/index.ts -> export/.js + * + * Each bundle is a self-contained classic-script IIFE (browser target, no + * chunks, linked source maps, unminified) whose entry point explicitly + * assigns its window global(s). Generated bundles and maps are gitignored. + * + * An iDevice that needs to deviate ships a `build.config.json` next to its + * `config.xml`, which REPLACES the convention for that iDevice: + * + * { + * "entries": [ + * { + * "entry": "src/index.ts", // relative to the iDevice dir + * "outdir": "edition", // relative to the iDevice dir + * "naming": "[dir]/slide-editor.bundle.[ext]", + * "globalName": "__slideEditorInit", // optional IIFE global + * "minify": true, // default false + * "sourcemap": "none", // default "linked" + * "externals": { // import name -> window global + * "fabric": "fabric", + * "dompurify": { "global": "DOMPurify", "default": true } + * } + * } + * ] + * } + * + * `externals` maps a bare import specifier to a global the page already + * provides (vendored under public/libs/), so the library is never inlined. + * With `"default": true` the shim also exposes the global as the module's + * default export (what `import X from '...'` consumers need). + * + * Type checking: every discovered iDevice that ships a `tsconfig.json` is + * checked with `tsc -p` (see --typecheck-only / --typecheck). + * + * Usage: + * bun scripts/build-idevices.ts # build every TS iDevice + * bun scripts/build-idevices.ts --typecheck-only # tsc -p only, no build + * bun scripts/build-idevices.ts --typecheck # tsc -p, then build + * bun scripts/build-idevices.ts --watch # rebuild on src changes + * bun scripts/build-idevices.ts --only slide # filter (comma-separated) + * + * Released under Attribution-ShareAlike 4.0 International License. + * Author: eXeLearning - https://exelearning.net + */ + +import { existsSync, readdirSync, readFileSync, watch } from 'fs'; +import { join, resolve } from 'path'; + +export const IDEVICES_BASE = resolve(import.meta.dir, '..', 'public/files/perm/idevices/base'); + +type SourcemapMode = 'linked' | 'none' | 'inline' | 'external'; + +export interface ExternalSpec { + global: string; + default: boolean; +} + +export interface BundleEntry { + /** iDevice folder name (also the default bundle basename). */ + idevice: string; + /** Short label for logs, e.g. 'interactive-video/edition'. */ + label: string; + entrypoint: string; + outdir: string; + naming: string; + globalName?: string; + minify: boolean; + sourcemap: SourcemapMode; + externals: Record; +} + +export interface TsIdevice { + name: string; + dir: string; + srcDir: string; + tsconfig: string | null; + entries: BundleEntry[]; +} + +function normalizeExternals(value: unknown): Record { + const out: Record = {}; + if (!value || typeof value !== 'object') { + return out; + } + for (const [name, spec] of Object.entries(value as Record)) { + if (typeof spec === 'string') { + out[name] = { global: spec, default: false }; + } else if (spec && typeof spec === 'object' && typeof (spec as { global?: unknown }).global === 'string') { + out[name] = { + global: (spec as { global: string }).global, + default: (spec as { default?: unknown }).default === true, + }; + } + } + return out; +} + +/** The build entries of one iDevice: its manifest, or the src/ convention. */ +export function resolveEntries(name: string, dir: string): BundleEntry[] { + const manifestPath = join(dir, 'build.config.json'); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + entries?: Array>; + }; + return (manifest.entries || []).map((raw, index) => { + const entry = String(raw.entry || ''); + const outdir = String(raw.outdir || ''); + if (!entry || !outdir) { + throw new Error(`${name}/build.config.json: entries[${index}] needs "entry" and "outdir"`); + } + return { + idevice: name, + label: `${name}/${outdir}`, + entrypoint: join(dir, entry), + outdir: join(dir, outdir), + naming: typeof raw.naming === 'string' ? raw.naming : `[dir]/${name}.[ext]`, + globalName: typeof raw.globalName === 'string' ? raw.globalName : undefined, + minify: raw.minify === true, + sourcemap: (typeof raw.sourcemap === 'string' ? raw.sourcemap : 'linked') as SourcemapMode, + externals: normalizeExternals(raw.externals), + }; + }); + } + const entries: BundleEntry[] = []; + for (const surface of ['edition', 'export'] as const) { + const entrypoint = join(dir, 'src', surface, 'index.ts'); + if (existsSync(entrypoint)) { + entries.push({ + idevice: name, + label: `${name}/${surface}`, + entrypoint, + outdir: join(dir, surface), + naming: `[dir]/${name}.[ext]`, + minify: false, + sourcemap: 'linked', + externals: {}, + }); + } + } + return entries; +} + +/** Every iDevice that keeps TypeScript sources under src/. */ +export function discoverTsIdevices(baseDir: string = IDEVICES_BASE, only?: string[]): TsIdevice[] { + const idevices: TsIdevice[] = []; + for (const entry of readdirSync(baseDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith('.')) { + continue; + } + if (only && only.length > 0 && !only.includes(entry.name)) { + continue; + } + const dir = join(baseDir, entry.name); + const srcDir = join(dir, 'src'); + if (!existsSync(srcDir)) { + continue; + } + const entries = resolveEntries(entry.name, dir); + if (entries.length === 0) { + continue; + } + const tsconfig = existsSync(join(dir, 'tsconfig.json')) ? join(dir, 'tsconfig.json') : null; + idevices.push({ name: entry.name, dir, srcDir, tsconfig, entries }); + } + return idevices; +} + +/** Bun plugin resolving the declared externals to page-provided globals. */ +function externalsPlugin(externals: Record): import('bun').BunPlugin { + const names = Object.keys(externals); + return { + name: 'idevice-externals', + setup(build) { + if (names.length === 0) { + return; + } + const filter = new RegExp(`^(${names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})$`); + build.onResolve({ filter }, args => ({ path: args.path, namespace: 'idevice-externals' })); + build.onLoad({ filter: /.*/, namespace: 'idevice-externals' }, args => { + const spec = externals[args.path]; + if (!spec) { + throw new Error(`No external mapping for '${args.path}'`); + } + const message = `iDevice bundle: window.${spec.global} is not loaded. Load its vendored script first.`; + return { + contents: ` + const __global = globalThis[${JSON.stringify(spec.global)}]; + if (!__global) { + throw new Error(${JSON.stringify(message)}); + } + module.exports = __global; + ${spec.default ? 'module.exports.default = __global;' : ''} + `, + loader: 'js', + }; + }); + }, + }; +} + +/** Build one entry; returns false (after printing every diagnostic) on failure. */ +async function buildEntry(entry: BundleEntry): Promise { + try { + const result = await Bun.build({ + entrypoints: [entry.entrypoint], + outdir: entry.outdir, + naming: entry.naming, + format: 'iife', + target: 'browser', + sourcemap: entry.sourcemap, + minify: entry.minify, + ...(entry.globalName ? { globalName: entry.globalName } : {}), + plugins: [externalsPlugin(entry.externals)], + }); + if (!result.success) { + console.error(`iDevice bundle FAILED: ${entry.label}`); + for (const log of result.logs) { + console.error(log); + } + return false; + } + for (const out of result.outputs) { + console.log(` ${out.path}`); + } + return true; + } catch (error) { + console.error(`iDevice bundle FAILED: ${entry.label}`); + console.error(error); + return false; + } +} + +/** Build every entry of one iDevice independently. */ +async function buildIdevice(idevice: TsIdevice): Promise { + const results = await Promise.all(idevice.entries.map(buildEntry)); + return results.every(ok => ok); +} + +/** tsc -p for every discovered iDevice that ships a tsconfig. */ +function typecheck(idevices: TsIdevice[]): boolean { + let ok = true; + for (const idevice of idevices) { + if (!idevice.tsconfig) { + continue; + } + console.log(`Type-checking ${idevice.name}…`); + const run = Bun.spawnSync(['bun', 'x', 'tsc', '-p', idevice.tsconfig], { stdout: 'inherit', stderr: 'inherit' }); + if (run.exitCode !== 0) { + ok = false; + } + } + return ok; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const isWatch = args.includes('--watch'); + const typecheckOnly = args.includes('--typecheck-only'); + const withTypecheck = typecheckOnly || args.includes('--typecheck'); + const onlyIndex = args.indexOf('--only'); + const only = onlyIndex > -1 ? (args[onlyIndex + 1] || '').split(',').filter(Boolean) : undefined; + + const idevices = discoverTsIdevices(IDEVICES_BASE, only); + if (idevices.length === 0) { + console.error('No TypeScript iDevices found' + (only ? ` matching --only ${only.join(',')}` : '')); + process.exit(1); + } + + if (withTypecheck && !typecheck(idevices)) { + process.exit(1); + } + if (typecheckOnly) { + console.log('Type checks passed.'); + return; + } + + console.log(`Building ${idevices.length} TypeScript iDevice(s): ${idevices.map(i => i.name).join(', ')}`); + const results = await Promise.all(idevices.map(buildIdevice)); + const ok = results.every(Boolean); + if (!isWatch) { + process.exit(ok ? 0 : 1); + } + + console.log('Watching src/ directories… (Ctrl+C to stop)'); + for (const idevice of idevices) { + let pending: ReturnType | null = null; + watch(idevice.srcDir, { recursive: true }, (_event, filename) => { + if (filename && /\.(spec|test)\.[tj]s$/.test(filename)) { + return; + } + if (pending) { + clearTimeout(pending); + } + pending = setTimeout(() => { + pending = null; + void buildIdevice(idevice); + }, 100); + }); + } +} + +if (import.meta.main) { + await main(); +} diff --git a/scripts/build-resource-bundles.js b/scripts/build-resource-bundles.js index d6026317fe..9c097f8ae8 100644 --- a/scripts/build-resource-bundles.js +++ b/scripts/build-resource-bundles.js @@ -93,6 +93,9 @@ function scanDirectory(dirPath, basePath = '') { // which drops these files (excludePatterns=['.test.js','.spec.js']). Listing // them would 404 on every fetch during static bundle assembly. if (entry.isFile() && /\.(test|spec)\.js$/.test(entry.name)) continue; + // Source maps are a development aid next to generated bundles (e.g. the + // TypeScript iDevices, ADR-2147-01); they must not ship inside resource ZIPs. + if (entry.isFile() && entry.name.endsWith('.map')) continue; const fullPath = path.join(dirPath, entry.name); const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name; diff --git a/scripts/build-resource-bundles.spec.ts b/scripts/build-resource-bundles.spec.ts index b8a174d9c4..2930d811a5 100644 --- a/scripts/build-resource-bundles.spec.ts +++ b/scripts/build-resource-bundles.spec.ts @@ -207,6 +207,27 @@ describe('build-resource-bundles', () => { } }); + it('should not list .map files in idevice/common staticFiles or idevices.zip', () => { + // Source maps next to generated TypeScript iDevice bundles + // (ADR-2147-01) must not ship in resource ZIPs or the static + // loose-file manifest. + const isSourceMap = (p: string) => p.endsWith('.map'); + const groups = [ + ...Object.values(manifest.staticFiles.idevices), + ...Object.values(manifest.staticFiles.common), + ]; + for (const entries of groups) { + for (const { s, t } of entries) { + expect(isSourceMap(s)).toBe(false); + expect(isSourceMap(t)).toBe(false); + } + } + const unzipped = unzipSync(new Uint8Array(fs.readFileSync(path.join(bundlesPath, 'idevices.zip')))); + for (const filePath of Object.keys(unzipped)) { + expect(isSourceMap(filePath)).toBe(false); + } + }); + it('should reference loose files that actually exist on disk', () => { // Spot-check one entry per group so assembly never 404s. const groups = [ diff --git a/scripts/build-slide-editor.ts b/scripts/build-slide-editor.ts deleted file mode 100644 index b4db8ecae2..0000000000 --- a/scripts/build-slide-editor.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Build script for the Slide iDevice editor bundle. - * - * Produces a single self-contained IIFE so the iDevice can be loaded - * by the eXeLearning workarea via a plain ')).toBe(false); + expect(isSafeLinkUrl('vbscript:x')).toBe(false); + }); + + it('rejects empty and blank input', () => { + expect(isSafeLinkUrl('')).toBe(false); + expect(isSafeLinkUrl(' ')).toBe(false); + }); +}); + +describe('normalizeLinkUrl', () => { + it('trims safe URLs and empties unsafe or non-string values', () => { + expect(normalizeLinkUrl(' https://example.com ')).toBe('https://example.com'); + expect(normalizeLinkUrl('javascript:alert(1)')).toBe(''); + expect(normalizeLinkUrl(42)).toBe(''); + expect(normalizeLinkUrl(undefined)).toBe(''); + }); +}); + +describe('videoEmbedUrl', () => { + it('maps YouTube URLs (watch, embed, short) to the embed form', () => { + expect(videoEmbedUrl('https://www.youtube.com/watch?v=abc-123')).toBe('https://www.youtube.com/embed/abc-123'); + expect(videoEmbedUrl('https://youtube.com/embed/xYz_9')).toBe('https://www.youtube.com/embed/xYz_9'); + expect(videoEmbedUrl('https://youtu.be/abc123')).toBe('https://www.youtube.com/embed/abc123'); + }); + + it('maps Vimeo URLs including unlisted hashes', () => { + expect(videoEmbedUrl('https://vimeo.com/12345')).toBe('https://player.vimeo.com/video/12345'); + expect(videoEmbedUrl('https://vimeo.com/video/12345')).toBe('https://player.vimeo.com/video/12345'); + expect(videoEmbedUrl('https://vimeo.com/12345/abcdef')).toBe('https://player.vimeo.com/video/12345?h=abcdef'); + }); + + it('maps EducaMadrid Mediateca URLs', () => { + expect(videoEmbedUrl('https://mediateca.educa.madrid.org/video/some-id')).toBe( + 'https://mediateca.educa.madrid.org/video/some-id/fs', + ); + expect(videoEmbedUrl('https://mediateca.educa.madrid.org/media/other_id')).toBe( + 'https://mediateca.educa.madrid.org/video/other_id/fs', + ); + }); + + it('returns null for direct media files and unknown providers', () => { + expect(videoEmbedUrl('https://example.com/movie.mp4')).toBeNull(); + expect(videoEmbedUrl('asset://videos/clip.webm')).toBeNull(); + expect(videoEmbedUrl('')).toBeNull(); + }); +}); diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/shared/urls.ts b/public/files/perm/idevices/base/three-sixty-viewer/src/shared/urls.ts new file mode 100644 index 0000000000..d58ffabb28 --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/shared/urls.ts @@ -0,0 +1,49 @@ +/** + * Safe URL handling for hotspot links and embedded video providers. + * + * Released under Attribution-ShareAlike 4.0 International License. + * Author: eXeLearning - https://exelearning.net + */ + +const SAFE_LINK_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:']; + +/** + * True when a link hotspot may open this URL: an absolute URL with an allowed + * scheme, a project asset (`asset://`), or a relative/anchor reference. + * Rejects `javascript:`, `data:`, `vbscript:` and any other scripting scheme. + */ +export function isSafeLinkUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return false; + if (trimmed.startsWith('asset://')) return true; + const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed); + if (!schemeMatch) { + // Relative path, anchor or protocol-relative URL. + return true; + } + return SAFE_LINK_SCHEMES.includes(`${schemeMatch[1]?.toLowerCase()}:`); +} + +/** Trim a user-entered URL; returns '' for unsafe or empty input. */ +export function normalizeLinkUrl(url: unknown): string { + if (typeof url !== 'string') return ''; + const trimmed = url.trim(); + return isSafeLinkUrl(trimmed) ? trimmed : ''; +} + +/** + * Map a pasted video page URL to an embeddable iframe src for a known + * provider, or return null for anything else (direct media files). The embed + * URL is rebuilt from the captured id rather than echoing the raw input, + * which is also safer than dropping an arbitrary URL into an iframe. + */ +export function videoEmbedUrl(url: string): string | null { + if (!url || typeof url !== 'string') return null; + const yt = /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/.exec(url); + if (yt) return `https://www.youtube.com/embed/${yt[1]}`; + const vimeo = /vimeo\.com\/(?:video\/)?(\d+)(?:\/(\w+))?/.exec(url); + if (vimeo) return `https://player.vimeo.com/video/${vimeo[1]}${vimeo[2] ? `?h=${vimeo[2]}` : ''}`; + const mediateca = /mediateca\.educa\.madrid\.org\/(?:video|media)\/([\w-]+)/.exec(url); + if (mediateca) return `https://mediateca.educa.madrid.org/video/${mediateca[1]}/fs`; + return null; +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/invalid/broken.json.txt b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/invalid/broken.json.txt new file mode 100644 index 0000000000..12e7aa914c --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/invalid/broken.json.txt @@ -0,0 +1 @@ +{ "version": 2, "scenes": [ this is not JSON diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/invalid/future-version.json b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/invalid/future-version.json new file mode 100644 index 0000000000..055b42c800 --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/invalid/future-version.json @@ -0,0 +1,7 @@ +{ + "version": 3, + "ideviceId": "idev-v3", + "startSceneId": "s1", + "scenes": [{ "id": "s1", "src": "asset://future.jpg", "volumetricLayers": [{ "depth": 4 }] }], + "newTopLevelField": { "anything": true } +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-1/full.json b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-1/full.json new file mode 100644 index 0000000000..cf3ea66e11 --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-1/full.json @@ -0,0 +1,10 @@ +{ + "ideviceId": "idev-v1-full", + "src": "asset://panoramas/plaza.jpg", + "alt": "Main square panorama", + "initialView": { "yaw": 30, "pitch": 10, "fov": 80 }, + "autorotate": { "enabled": true, "speed": 2 }, + "zoomEnabled": false, + "fullscreenEnabled": true, + "showNavControls": false +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-1/minimal.json b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-1/minimal.json new file mode 100644 index 0000000000..bdb183e4ea --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-1/minimal.json @@ -0,0 +1,3 @@ +{ + "src": "asset://panoramas/street.jpg" +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-2/tour.json b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-2/tour.json new file mode 100644 index 0000000000..21cee6cb28 --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-2/tour.json @@ -0,0 +1,92 @@ +{ + "version": 2, + "ideviceId": "idev-v2-tour", + "startSceneId": "scene-hall", + "scenes": [ + { + "id": "scene-hall", + "title": "Hall", + "src": "asset://panoramas/hall.jpg", + "alt": "Entrance hall", + "description": "The entrance hall.", + "projection": "equirectangular", + "initialView": { "yaw": 15, "pitch": -5, "fov": 70 }, + "hotspots": [ + { + "id": "hs-to-patio", + "label": "Go to the patio", + "icon": "circle", + "yaw": 40, + "pitch": 0, + "x": 50, + "y": 50, + "action": { "type": "goToScene", "payload": { "sceneId": "scene-patio" } } + }, + { + "id": "hs-info", + "label": "About this building", + "icon": "circle", + "yaw": -30, + "pitch": 10, + "x": 50, + "y": 50, + "action": { "type": "text", "payload": { "html": "

Built in 1901.

" } } + } + ] + }, + { + "id": "scene-patio", + "title": "Patio", + "src": "asset://images/patio.jpg", + "alt": "The patio, flat photo", + "description": "", + "projection": "flat", + "initialView": { "yaw": 0, "pitch": 0, "fov": 75 }, + "hotspots": [ + { + "id": "hs-fountain", + "label": "The fountain", + "icon": "circle", + "yaw": 0, + "pitch": 0, + "x": 25, + "y": 70, + "action": { + "type": "image", + "payload": { "src": "asset://images/fountain.jpg", "alt": "Fountain detail", "caption": "The old fountain" } + } + }, + { + "id": "hs-video", + "label": "Watch the tour video", + "icon": "circle", + "yaw": 0, + "pitch": 0, + "x": 80, + "y": 30, + "action": { "type": "video", "payload": { "src": "https://youtu.be/abc123", "poster": "" } } + }, + { + "id": "hs-web", + "label": "Official website", + "icon": "circle", + "yaw": 0, + "pitch": 0, + "x": 10, + "y": 10, + "action": { "type": "link", "payload": { "url": "https://example.com", "newTab": false } } + } + ] + } + ], + "behaviour": { + "autorotate": { "enabled": false, "speed": 1.5 }, + "zoomEnabled": true, + "fullscreenEnabled": false, + "showNavControls": true, + "renderQuality": "medium", + "showLabels": true, + "labelPosition": "left", + "imageAdjustments": { "brightness": 1.2, "contrast": 0.9, "saturation": 1.1 } + } +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-2/with-unknown-action.json b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-2/with-unknown-action.json new file mode 100644 index 0000000000..653dfb13ae --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/test/fixtures/version-2/with-unknown-action.json @@ -0,0 +1,29 @@ +{ + "version": 2, + "ideviceId": "idev-v2-future-action", + "startSceneId": "s1", + "scenes": [ + { + "id": "s1", + "title": "Scene with a future hotspot", + "src": "asset://panoramas/one.jpg", + "alt": "One", + "description": "", + "projection": "equirectangular", + "initialView": { "yaw": 0, "pitch": 0, "fov": 75 }, + "hotspots": [ + { + "id": "hs-future", + "label": "Future thing", + "icon": "circle", + "yaw": 12, + "pitch": -4, + "x": 50, + "y": 50, + "action": { "type": "quiz3d", "payload": { "question": "Why?", "options": ["a", "b"], "nested": { "deep": true } } } + } + ] + } + ], + "behaviour": {} +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/tsconfig.json b/public/files/perm/idevices/base/three-sixty-viewer/tsconfig.json new file mode 100644 index 0000000000..616c0fe053 --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "Preserve", + "moduleResolution": "Bundler", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "useUnknownInCatchVariables": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} From ea17acc8b8d60957a86ac67f58b461765a65a407 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:35:31 +0000 Subject: [PATCH 03/11] refactor(three-sixty-viewer): migrate viewer runtime to TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-written export/three-sixty-viewer.js (now a generated, gitignored IIFE built from src/export/index.ts) with typed, modular code: - src/viewer/: browser layer shared with the editor preview — structural three.js types (the vendored library stays a page global; no @types/three, no bundled copy), panorama renderer owning every WebGL resource it creates, flat-image renderer with letterbox-aware positioning, hotspot overlay, nav/fullscreen/drag-capture controls, render-loop + disposer-bag lifecycle helpers, and asset:// resolution behind a boundary. - src/export/: per-node instances in a WeakMap-backed registry instead of one mutable global list. Each instance owns its scene controller, renderers, hotspot layer, controls, animation frame, resize observer and modal; destroy() releases all of them and re-rendering a node disposes its predecessor. Multiple viewers per page stay fully independent. - Accessible content modal (role=dialog, focus trap, Escape, focus restoration), safe link opening (scheme allowlist + noopener), explicit handling of unsupported future hotspot actions, WebGL/three.js/no-image fallbacks, and prefers-reduced-motion disabling autorotation. - renderView/renderBehaviour/init keep the exact exe_export.js contract and window.$threesixtyviewer is assigned explicitly by the entry point. Documents with version > 2 render an accessible notice instead of being guessed at. 84 colocated Vitest specs (viewer + export) run against an injectable THREE mock and manual frame scheduler. Co-Authored-By: Claude --- .../export/three-sixty-viewer.js | 1250 ---------------- .../export/three-sixty-viewer.test.js | 1306 ----------------- .../src/export/actions.spec.ts | 90 ++ .../three-sixty-viewer/src/export/actions.ts | 81 + .../three-sixty-viewer/src/export/index.ts | 35 + .../src/export/instance.spec.ts | 169 +++ .../three-sixty-viewer/src/export/instance.ts | 185 +++ .../src/export/modal.spec.ts | 128 ++ .../three-sixty-viewer/src/export/modal.ts | 173 +++ .../src/export/renderer.spec.ts | 141 ++ .../three-sixty-viewer/src/export/renderer.ts | 125 ++ .../src/export/runtime.spec.ts | 140 ++ .../three-sixty-viewer/src/export/runtime.ts | 126 ++ .../base/three-sixty-viewer/src/globals.d.ts | 60 + .../three-sixty-viewer/src/test/helpers.ts | 258 ++++ .../src/viewer/assets.spec.ts | 52 + .../three-sixty-viewer/src/viewer/assets.ts | 35 + .../src/viewer/controls.spec.ts | 110 ++ .../three-sixty-viewer/src/viewer/controls.ts | 181 +++ .../src/viewer/flat-image-renderer.spec.ts | 47 + .../src/viewer/flat-image-renderer.ts | 61 + .../src/viewer/hotspot-renderer.spec.ts | 90 ++ .../src/viewer/hotspot-renderer.ts | 127 ++ .../src/viewer/lifecycle.spec.ts | 99 ++ .../src/viewer/lifecycle.ts | 124 ++ .../src/viewer/panorama-renderer.spec.ts | 176 +++ .../src/viewer/panorama-renderer.ts | 277 ++++ .../src/viewer/scene-controller.spec.ts | 98 ++ .../src/viewer/scene-controller.ts | 81 + .../three-sixty-viewer/src/viewer/types.ts | 111 ++ 30 files changed, 3380 insertions(+), 2556 deletions(-) delete mode 100644 public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js delete mode 100644 public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.test.js create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/actions.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/actions.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/index.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/instance.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/instance.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/modal.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/modal.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/renderer.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/renderer.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/runtime.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/export/runtime.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/globals.d.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/test/helpers.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/assets.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/assets.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/controls.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/controls.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/flat-image-renderer.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/flat-image-renderer.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/hotspot-renderer.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/hotspot-renderer.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/lifecycle.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/lifecycle.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/panorama-renderer.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/panorama-renderer.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/scene-controller.spec.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/scene-controller.ts create mode 100644 public/files/perm/idevices/base/three-sixty-viewer/src/viewer/types.ts diff --git a/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js b/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js deleted file mode 100644 index 4857adb262..0000000000 --- a/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js +++ /dev/null @@ -1,1250 +0,0 @@ -/* eslint-disable no-undef */ -/** - * 360° panorama viewer iDevice (export/runtime code). - * Renders a v2 virtual tour: scenes + hotspots + accessible content modals. - * v1 single-image data is migrated transparently into a single-scene tour. - * - * JSON iDevice API (called by public/app/common/exe_export.js): - * renderView(data, accesibility, template) -> HTML string - * renderBehaviour(data, accesibility) -> attach three.js viewer - * init(data, accesibility) -> engine hook (no-op here) - * - * Released under Attribution-ShareAlike 4.0 International License. - * License: http://creativecommons.org/licenses/by-sa/4.0/ - */ - -var $threesixtyviewer = { - cssClass: 'three-sixty-viewer', - SCHEMA_VERSION: 2, - HOTSPOT_ACTION_TYPES: ['goToScene', 'text', 'image', 'video', 'link'], - RENDER_QUALITY_VALUES: ['low', 'medium', 'high'], - LABEL_POSITION_VALUES: ['right', 'left', 'top', 'bottom'], - _instances: [], - - // ───────────────────────────────────────────────────────────────────── - // JSON iDevice engine API (called by public/app/common/exe_export.js) - // ───────────────────────────────────────────────────────────────────── - - renderView: function (data, _accesibility, template) { - var state = this.normalize(data); - var startScene = this.getStartScene(state); - var altAttr = this.escapeAttr((startScene && startScene.alt) || '360° panorama'); - var body = '
'; - var tpl = typeof template === 'string' && template ? template : '{content}'; - return tpl.replace('{content}', body); - }, - - renderBehaviour: function (data, _accesibility) { - var state = this.normalize(data); - var id = data && data.ideviceId; - var node = id ? document.getElementById(id) : null; - if (!node) return; - - this._disposeNode(node); - - var wrapper = node.querySelector('.three-sixty-viewer-wrapper'); - if (!wrapper) { - wrapper = document.createElement('div'); - wrapper.className = 'three-sixty-viewer-wrapper'; - node.appendChild(wrapper); - } - while (wrapper.firstChild) wrapper.removeChild(wrapper.firstChild); - - var startScene = this.getStartScene(state); - wrapper.setAttribute('role', 'region'); - wrapper.setAttribute('aria-label', (startScene && startScene.alt) || '360° panorama'); - - if (!startScene || !startScene.src) { - this.renderFallback(wrapper, state, '(no image)'); - return; - } - if (!this.hasWebGL()) { - this.renderFallback(wrapper, state, (startScene && startScene.alt) || ''); - return; - } - if (typeof THREE === 'undefined') { - this.renderFallback(wrapper, state, (startScene && startScene.alt) || ''); - return; - } - - this._createViewer(wrapper, state); - }, - - init: (_data, _accesibility) => { - // no-op (engine contract) - }, - - // ───────────────────────────────────────────────────────────────────── - // State helpers (mirror edition/three-sixty-viewer.js) - // ───────────────────────────────────────────────────────────────────── - - normalize: function (data) { - var raw = data && typeof data === 'object' ? data : {}; - var v2 = this._migrateToV2(raw); - var scenes = - Array.isArray(v2.scenes) && v2.scenes.length > 0 - ? v2.scenes.map(this._normalizeScene, this) - : [this._defaultScene('scene-1')]; - var startSceneId = this._resolveStartSceneId(v2.startSceneId, scenes); - return { - version: this.SCHEMA_VERSION, - ideviceId: typeof raw.ideviceId === 'string' ? raw.ideviceId : '', - startSceneId: startSceneId, - scenes: scenes, - behaviour: this._normalizeBehaviour(v2.behaviour), - }; - }, - - _migrateToV2: function (data) { - if (data && data.version >= 2 && Array.isArray(data.scenes)) { - return { - scenes: data.scenes, - startSceneId: typeof data.startSceneId === 'string' ? data.startSceneId : '', - behaviour: data.behaviour && typeof data.behaviour === 'object' ? data.behaviour : {}, - }; - } - var hasV1Fields = - data && - (typeof data.src === 'string' || - typeof data.alt === 'string' || - data.initialView || - data.autorotate || - 'zoomEnabled' in data || - 'fullscreenEnabled' in data); - if (hasV1Fields) { - var scene = this._defaultScene('scene-1'); - scene.src = typeof data.src === 'string' ? data.src : ''; - scene.alt = typeof data.alt === 'string' ? data.alt : ''; - scene.initialView = this._normalizeInitialView(data.initialView); - return { - scenes: [scene], - startSceneId: 'scene-1', - behaviour: { - autorotate: data.autorotate || {}, - zoomEnabled: data.zoomEnabled, - fullscreenEnabled: data.fullscreenEnabled, - showNavControls: data.showNavControls, - }, - }; - } - return { scenes: [], startSceneId: '', behaviour: {} }; - }, - - _defaultScene: id => ({ - id: id || 'scene-' + Math.floor(Math.random() * 1e9).toString(36), - title: '', - src: '', - alt: '', - description: '', - projection: 'equirectangular', - initialView: { yaw: 0, pitch: 0, fov: 75 }, - hotspots: [], - }), - - _normalizeInitialView: function (iv) { - var s = iv && typeof iv === 'object' ? iv : {}; - return { - yaw: this.clamp(this.toNumber(s.yaw, 0), -180, 180), - pitch: this.clamp(this.toNumber(s.pitch, 0), -90, 90), - fov: this.clamp(this.toNumber(s.fov, 75), 30, 120), - }; - }, - - _normalizeScene: function (s, index) { - var src = s && typeof s === 'object' ? s : {}; - var fallbackId = 'scene-' + (typeof index === 'number' ? index + 1 : 1); - var hotspots = Array.isArray(src.hotspots) ? src.hotspots.map(this._normalizeHotspot, this) : []; - return { - id: typeof src.id === 'string' && src.id ? src.id : fallbackId, - title: typeof src.title === 'string' ? src.title : '', - src: typeof src.src === 'string' ? src.src : '', - alt: typeof src.alt === 'string' ? src.alt : '', - description: typeof src.description === 'string' ? src.description : '', - projection: src.projection === 'flat' ? 'flat' : 'equirectangular', - initialView: this._normalizeInitialView(src.initialView), - hotspots: hotspots, - }; - }, - - _normalizeHotspot: function (h) { - var src = h && typeof h === 'object' ? h : {}; - var actionRaw = src.action && typeof src.action === 'object' ? src.action : {}; - var type = this.HOTSPOT_ACTION_TYPES.indexOf(actionRaw.type) >= 0 ? actionRaw.type : 'text'; - var payload = actionRaw.payload && typeof actionRaw.payload === 'object' ? actionRaw.payload : {}; - return { - id: typeof src.id === 'string' && src.id ? src.id : 'hs-' + Math.floor(Math.random() * 1e9).toString(36), - label: typeof src.label === 'string' ? src.label : '', - icon: typeof src.icon === 'string' ? src.icon : 'circle', - yaw: this.clamp(this.toNumber(src.yaw, 0), -180, 180), - pitch: this.clamp(this.toNumber(src.pitch, 0), -90, 90), - x: this.clamp(this.toNumber(src.x, 50), 0, 100), - y: this.clamp(this.toNumber(src.y, 50), 0, 100), - action: { type: type, payload: this._normalizeHotspotPayload(type, payload) }, - }; - }, - - _normalizeHotspotPayload: (type, p) => { - switch (type) { - case 'goToScene': - return { sceneId: typeof p.sceneId === 'string' ? p.sceneId : '' }; - case 'text': - return { html: typeof p.html === 'string' ? p.html : '' }; - case 'image': - return { - src: typeof p.src === 'string' ? p.src : '', - alt: typeof p.alt === 'string' ? p.alt : '', - caption: typeof p.caption === 'string' ? p.caption : '', - }; - case 'video': - return { - src: typeof p.src === 'string' ? p.src : '', - poster: typeof p.poster === 'string' ? p.poster : '', - }; - case 'link': - return { - url: typeof p.url === 'string' ? p.url : '', - newTab: p.newTab !== false, - }; - default: - return {}; - } - }, - - _normalizeBehaviour: function (b) { - var src = b && typeof b === 'object' ? b : {}; - var ar = src.autorotate && typeof src.autorotate === 'object' ? src.autorotate : {}; - var ia = src.imageAdjustments && typeof src.imageAdjustments === 'object' ? src.imageAdjustments : {}; - var renderQuality = this.RENDER_QUALITY_VALUES.indexOf(src.renderQuality) >= 0 ? src.renderQuality : 'high'; - var labelPosition = this.LABEL_POSITION_VALUES.indexOf(src.labelPosition) >= 0 ? src.labelPosition : 'right'; - return { - autorotate: { - enabled: !!ar.enabled, - speed: this.clamp(this.toNumber(ar.speed, 1), 0, 10), - }, - zoomEnabled: src.zoomEnabled !== false, - fullscreenEnabled: src.fullscreenEnabled !== false, - showNavControls: src.showNavControls !== false, - renderQuality: renderQuality, - showLabels: src.showLabels !== false, - labelPosition: labelPosition, - imageAdjustments: { - brightness: this.clamp(this.toNumber(ia.brightness, 1), 0.1, 3), - contrast: this.clamp(this.toNumber(ia.contrast, 1), 0.1, 3), - saturation: this.clamp(this.toNumber(ia.saturation, 1), 0, 3), - }, - }; - }, - - _resolveStartSceneId: (requested, scenes) => { - if (!Array.isArray(scenes) || scenes.length === 0) return ''; - if (typeof requested === 'string' && requested) { - for (var i = 0; i < scenes.length; i++) { - if (scenes[i].id === requested) return requested; - } - } - return scenes[0].id; - }, - - getStartScene: state => { - if (!state || !Array.isArray(state.scenes) || state.scenes.length === 0) return null; - for (var i = 0; i < state.scenes.length; i++) { - if (state.scenes[i].id === state.startSceneId) return state.scenes[i]; - } - return state.scenes[0]; - }, - - findSceneById: (state, sceneId) => { - if (!state || !Array.isArray(state.scenes)) return null; - for (var i = 0; i < state.scenes.length; i++) { - if (state.scenes[i].id === sceneId) return state.scenes[i]; - } - return null; - }, - - toNumber: (v, fallback) => { - var n = typeof v === 'number' ? v : parseFloat(v); - return isFinite(n) ? n : fallback; - }, - - clamp: (v, min, max) => { - if (v < min) return min; - if (v > max) return max; - return v; - }, - - /** - * Rectangle a `object-fit: contain` image occupies inside a box (letterbox - * aware). Falls back to the full box when natural dimensions are unknown. - * Mirrors edition/three-sixty-viewer.js so editor and runtime agree on the - * flat-image hotspot coordinate basis. - */ - containedImageRect: (naturalW, naturalH, boxW, boxH) => { - if (!naturalW || !naturalH || !boxW || !boxH) { - return { left: 0, top: 0, width: boxW || 0, height: boxH || 0 }; - } - var scale = Math.min(boxW / naturalW, boxH / naturalH); - var w = naturalW * scale; - var h = naturalH * scale; - return { left: (boxW - w) / 2, top: (boxH - h) / 2, width: w, height: h }; - }, - - escapeAttr: s => - String(s == null ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'), - - escapeHtml: s => - String(s == null ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>'), - - /** - * Make WebGL output match the source panorama's apparent brightness/colour. - */ - applyColorManagement: renderer => { - if (typeof THREE === 'undefined' || !renderer) return; - if (THREE.ColorManagement && 'enabled' in THREE.ColorManagement) { - THREE.ColorManagement.enabled = true; - } - if ('outputColorSpace' in renderer && typeof THREE.SRGBColorSpace !== 'undefined') { - renderer.outputColorSpace = THREE.SRGBColorSpace; - } else if ('outputEncoding' in renderer && typeof THREE.sRGBEncoding !== 'undefined') { - renderer.outputEncoding = THREE.sRGBEncoding; - } - if ('toneMapping' in renderer && typeof THREE.NoToneMapping !== 'undefined') { - renderer.toneMapping = THREE.NoToneMapping; - renderer.toneMappingExposure = 1.0; - } - }, - - applyTextureColorSpace: texture => { - if (!texture || typeof THREE === 'undefined') return; - if ('colorSpace' in texture && typeof THREE.SRGBColorSpace !== 'undefined') { - texture.colorSpace = THREE.SRGBColorSpace; - } else if ('encoding' in texture && typeof THREE.sRGBEncoding !== 'undefined') { - texture.encoding = THREE.sRGBEncoding; - } - }, - - /** - * Legacy / test helper: extract state from a DOM node that carries either - * a data-idevice-json-data attribute or a nested ', alt: '' }], + }); + const html = formHtml(state, identity); + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); +}); + +describe('unsupportedVersionHtml', () => { + it('names the version and promises data preservation', () => { + const html = unsupportedVersionHtml(3, identity); + expect(html).toContain('role="alert"'); + expect(html).toContain('format version 3'); + expect(html).toContain('saving keeps it unchanged'); + }); +}); diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/edition/form.ts b/public/files/perm/idevices/base/three-sixty-viewer/src/edition/form.ts new file mode 100644 index 0000000000..3d15185738 --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/edition/form.ts @@ -0,0 +1,146 @@ +/** + * Pure HTML builders for the editor form. Everything derives from the typed + * editor state; no listeners are attached here. Control ids and classes are + * part of the editor's public surface (CSS + Playwright) and must not change. + * + * Released under Attribution-ShareAlike 4.0 International License. + * Author: eXeLearning - https://exelearning.net + */ + +import { escapeAttr, escapeHtml, truncateLabel } from '../shared/html'; +import type { Translate } from './i18n'; +import type { EditorState } from './state'; + +export function formHtml(state: EditorState, tr: Translate): string { + const scene = state.activeScene(); + const behaviour = state.doc.behaviour; + const isFlat = scene.projection === 'flat'; + // The "Initial view" controls (yaw/pitch/fov) only make sense on a 360° + // panorama; a flat photo is shown undistorted with no camera to aim. + const initialViewFieldset = isFlat + ? '' + : ` +
+ ${tr('Initial view')} +
+ + + + + + +
+
`; + return ` +
+

${tr('Add equirectangular 360° images (2:1 aspect), or uncheck “360° panorama image” to use a regular flat photo. The viewer uses WebGL for 360° scenes.')}

+
+ +
+ ${tr('Scenes')} +
+
+ +
+
+ +
+ ${tr('Active scene')} +
+ + +
+
+ +
+ + ${scene.src ? escapeHtml(truncateLabel(scene.src)) : tr('No image selected')} + +
+ +
+
+ + ${tr('Uncheck for a regular flat photo (no 360° effect).')} +
+
+ + +
+
+ + +
+ ${initialViewFieldset} + +
+ ${tr('Hotspots')} +

${isFlat ? tr('Click on the image to place a hotspot, or drag an existing hotspot to move it.') : tr('Click on the panorama to place a hotspot, or drag an existing hotspot to move it.')}

+
+
+ + +
+ +
+
+ +
+ ${tr('Controls')} +
+ + + +
+
+ + + + +
+
+ +
+
+

${tr('Select an image to see a live preview.')}

+
+
+ `; +} + +/** + * Message shown INSTEAD of the form when the stored document comes from a + * newer schema version. Saving passes the original payload through untouched. + */ +export function unsupportedVersionHtml(version: number, tr: Translate): string { + return ` +
+ +
+ `; +} diff --git a/public/files/perm/idevices/base/three-sixty-viewer/src/edition/hotspot-editor.spec.ts b/public/files/perm/idevices/base/three-sixty-viewer/src/edition/hotspot-editor.spec.ts new file mode 100644 index 0000000000..38762c594c --- /dev/null +++ b/public/files/perm/idevices/base/three-sixty-viewer/src/edition/hotspot-editor.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { createSequentialIdGenerator } from '../shared/ids'; +import { hydrateDocument } from '../shared/schema'; +import type { Hotspot } from '../shared/types'; +import { actionTypeLabel, actionTypeOptionsHtml, payloadInputsHtml } from './hotspot-editor'; +import { createDefaultHotspot } from '../shared/normalization'; +import { createEditorState } from './state'; + +const identity = (text: string): string => text; + +function makeState() { + const result = hydrateDocument( + { version: 2, scenes: [{ id: 'a', title: 'A' }, { id: 'b', title: 'B' }] }, + createSequentialIdGenerator(), + ); + if (result.status !== 'ok') throw new Error('fixture'); + return createEditorState(result.document, createSequentialIdGenerator()); +} + +function hotspotWith(action: Hotspot['action']): Hotspot { + return { ...createDefaultHotspot('h'), action }; +} + +describe('actionTypeLabel', () => { + it('labels every known type and echoes unknown ones', () => { + expect(actionTypeLabel('goToScene', identity)).toBe('Go to scene'); + expect(actionTypeLabel('text', identity)).toBe('Text'); + expect(actionTypeLabel('image', identity)).toBe('Image'); + expect(actionTypeLabel('video', identity)).toBe('Video'); + expect(actionTypeLabel('link', identity)).toBe('External link'); + expect(actionTypeLabel('quiz3d', identity)).toBe('quiz3d'); + }); +}); + +describe('actionTypeOptionsHtml', () => { + it('marks the current type selected', () => { + const html = actionTypeOptionsHtml(hotspotWith({ type: 'video', payload: { src: '', poster: '' } }), identity); + expect(html).toContain('