From 3ccee679ae8b2299cc4c27161277842962f7ba05 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:10:40 +0000 Subject: [PATCH 1/9] 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 + bun.lock | 78 +++-- ...06-typescript-idevices-build-convention.md | 124 +++++++ doc/architecture/adr/records.md | 8 +- 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-slide-editor.ts | 79 ----- .../playwright/helpers/workarea-helpers.ts | 69 +++- vitest.config.mts | 30 +- 17 files changed, 854 insertions(+), 125 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 9f01c7a7a2..588d4087ef 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 45c7c5ca4c..50dd4b4cf7 100644 --- a/.gitignore +++ b/.gitignore @@ -121,6 +121,6 @@ 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 f4b3b721e2..6ace6cecf1 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 @@ -344,6 +346,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/bun.lock b/bun.lock index 9eabdfe2e2..072d901f80 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ "mime-types": "^3.0.2", "mysql2": "^3.18.2", "nunjucks": "^3.2.4", - "pdfjs-dist": "6.0.227", + "pdfjs-dist": "6.1.200", "uuid": "^14.0.0", "ws": "^8.19.0", "y-websocket": "^3.0.0", @@ -43,7 +43,7 @@ "@types/bcryptjs": "^3.0.0", "@types/fs-extra": "^11.0.4", "@types/mime-types": "^3.0.1", - "@types/node": "^25.3.3", + "@types/node": "^26.1.0", "@types/nunjucks": "^3.2.6", "@types/uuid": "^11.0.0", "@types/ws": "^8.18.1", @@ -51,7 +51,7 @@ "@vitest/ui": "^4.0.18", "cross-env": "^10.1.0", "dompurify": "^3.2.4", - "electron": "^42.0.0", + "electron": "^43.0.0", "electron-builder": "^26.8.1", "esbuild": "^0.28.0", "fabric": "^7.4.0", @@ -59,7 +59,7 @@ "http-proxy-middleware": "^4.0.0", "kill-port": "^2.0.1", "sass": "^1.97.3", - "typescript": "^6.0.2", + "typescript": "^7.0.2", "vite": "^8.0.0", "vitest": "^4.0.18", "wait-on": "^9.0.4", @@ -149,6 +149,8 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="], + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], @@ -565,7 +567,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], "@types/nunjucks": ["@types/nunjucks@3.2.6", "", {}, "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w=="], @@ -583,7 +585,45 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], @@ -671,8 +711,6 @@ "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="], @@ -875,7 +913,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@42.0.1", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-d8HnycE970DGESe91Nj30eonFBUcAI9EZ1TwUGJVzSAnJZdh0BkFEinAXjdklvDYst+bVDc8HsksCuqVLrnqdg=="], + "electron": ["electron@43.2.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA=="], "electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], @@ -929,8 +967,6 @@ "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], - "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - "extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="], "fabric": ["fabric@7.4.0", "", { "optionalDependencies": { "canvas": "^3.2.0", "jsdom": "^26.1.0" } }, "sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA=="], @@ -949,8 +985,6 @@ "fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], @@ -1321,12 +1355,10 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pdfjs-dist": ["pdfjs-dist@6.0.227", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg=="], + "pdfjs-dist": ["pdfjs-dist@6.1.200", "", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw=="], "pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -1551,7 +1583,7 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], @@ -1559,7 +1591,7 @@ "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="], @@ -1645,8 +1677,6 @@ "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - "yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1707,8 +1737,6 @@ "@types/ws/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], - "@types/yauzl/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], - "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], "app-builder-lib/@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="], @@ -1789,6 +1817,8 @@ "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "happy-dom/@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + "hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "html-encoding-sniffer/@exodus/bytes": ["@exodus/bytes@1.10.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg=="], @@ -1877,8 +1907,6 @@ "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "@types/yauzl/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], "app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -1961,6 +1989,8 @@ "fs-minipass/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "happy-dom/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], 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/architecture/adr/records.md b/doc/architecture/adr/records.md index d4e076608e..777181d5f7 100644 --- a/doc/architecture/adr/records.md +++ b/doc/architecture/adr/records.md @@ -10,10 +10,16 @@ changes status, update the table and the per-status lists below. | ID | Title | Status | Date | |---|---|---|---| | ADR-0000 | Template | Template | — | +| ADR-0006 | TypeScript iDevices: src/ sources compiled by one convention-based build | Proposed | 2026-07-30 | + +IDs ADR-0001 through ADR-0005 are reserved by the upstream Interactive Video +refactor (exelearning/exelearning#2147) and are intentionally skipped here so +the two histories can merge without renumbering. ## Proposed ADRs -_No proposed ADRs yet._ +- [ADR-0006](ADR-0006-typescript-idevices-build-convention.md) — TypeScript + iDevices: `src/` sources compiled by one convention-based build. ## Accepted ADRs 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 86c8fe5810..df59fd0025 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,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 d56110c507..c8a953467c 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", "build:static": "bun run build:all && bun scripts/build-static-bundle.ts", "css:node": "bun x sass assets/styles/main.scss public/style/workarea/main.css --style=compressed --no-source-map", @@ -22,7 +22,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", "upload:bundles": "bun scripts/upload-bundle-analysis.js", "predev": "bun scripts/setup-local.js", "seed": "bun run src/db/seed.ts", 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 8c1301c31c..b9ff037ce5 100644 --- a/scripts/build-resource-bundles.js +++ b/scripts/build-resource-bundles.js @@ -77,6 +77,9 @@ function scanDirectory(dirPath, basePath = '') { for (const entry of entries) { if (entry.name.startsWith('.')) continue; + // Source maps are a development aid next to generated bundles (e.g. the + // TypeScript iDevices, ADR-0006); 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-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 efb1dc0ee8a1e5840c2f8f8b9aa8f094b2f947e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:35:31 +0000 Subject: [PATCH 3/9] 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 | 1242 ---------------- .../export/three-sixty-viewer.test.js | 1271 ----------------- .../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(+), 2513 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 e37c93a8ad..0000000000 --- a/public/files/perm/idevices/base/three-sixty-viewer/export/three-sixty-viewer.js +++ /dev/null @@ -1,1242 +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('