From a4f8340b0428ca94f02242dc50bf03c312fbe1e8 Mon Sep 17 00:00:00 2001 From: Marijn Doeve Date: Sun, 12 Jul 2026 19:08:21 +0200 Subject: [PATCH] Migrate frontend to TypeScript with Deno-based tooling (#206) Compiles assets/*.ts via sensiolabs/typescript-bundle (standalone SWC binary) and adds Deno for formatting, linting, type-checking, and tests, keeping the project's no-Node/npm approach intact. Wires all four into CI, the Justfile, and the pre-commit hook. --- .githooks/pre-commit | 22 ++- .github/workflows/ci.yml | 20 ++ CLAUDE.md | 10 +- Dockerfile | 7 + Justfile | 10 + assets/{backoffice.js => backoffice.ts} | 11 +- assets/bootstrap.js | 1 - assets/bootstrap.ts | 1 + .../bo/form_collection_controller.js | 136 -------------- .../bo/form_collection_controller.ts | 172 ++++++++++++++++++ .../bo/form_collection_controller_test.ts | 114 ++++++++++++ assets/controllers/bo/modal_controller.js | 49 ----- assets/controllers/bo/modal_controller.ts | 66 +++++++ assets/controllers/bo/popover_controller.js | 13 -- assets/controllers/bo/popover_controller.ts | 19 ++ .../bo/question_list_controller.js | 123 ------------- .../bo/question_list_controller.ts | 161 ++++++++++++++++ .../bo/question_list_controller_test.ts | 140 ++++++++++++++ assets/controllers/bo/quiz_controller.js | 28 --- assets/controllers/bo/quiz_controller.ts | 35 ++++ .../controllers/csrf_protection_controller.js | 81 --------- .../controllers/csrf_protection_controller.ts | 129 +++++++++++++ .../csrf_protection_controller_test.ts | 119 ++++++++++++ assets/controllers/elimination_controller.js | 14 -- assets/controllers/elimination_controller.ts | 14 ++ assets/controllers/fullscreen_controller.js | 40 ---- assets/controllers/fullscreen_controller.ts | 51 ++++++ .../controllers/fullscreen_controller_test.ts | 75 ++++++++ assets/{quiz.js => quiz.ts} | 4 +- assets/{stimulus.js => stimulus.ts} | 2 +- assets/types/global.d.ts | 11 ++ composer.json | 1 + composer.lock | 58 +++++- config/bundles.php | 2 + config/packages/asset_mapper.yaml | 4 + config/reference.php | 11 ++ deno.json | 32 ++++ deno.lock | 96 ++++++++++ importmap.php | 4 +- symfony.lock | 3 + 40 files changed, 1391 insertions(+), 498 deletions(-) rename assets/{backoffice.js => backoffice.ts} (81%) delete mode 100644 assets/bootstrap.js create mode 100644 assets/bootstrap.ts delete mode 100644 assets/controllers/bo/form_collection_controller.js create mode 100644 assets/controllers/bo/form_collection_controller.ts create mode 100644 assets/controllers/bo/form_collection_controller_test.ts delete mode 100644 assets/controllers/bo/modal_controller.js create mode 100644 assets/controllers/bo/modal_controller.ts delete mode 100644 assets/controllers/bo/popover_controller.js create mode 100644 assets/controllers/bo/popover_controller.ts delete mode 100644 assets/controllers/bo/question_list_controller.js create mode 100644 assets/controllers/bo/question_list_controller.ts create mode 100644 assets/controllers/bo/question_list_controller_test.ts delete mode 100644 assets/controllers/bo/quiz_controller.js create mode 100644 assets/controllers/bo/quiz_controller.ts delete mode 100644 assets/controllers/csrf_protection_controller.js create mode 100644 assets/controllers/csrf_protection_controller.ts create mode 100644 assets/controllers/csrf_protection_controller_test.ts delete mode 100644 assets/controllers/elimination_controller.js create mode 100644 assets/controllers/elimination_controller.ts delete mode 100644 assets/controllers/fullscreen_controller.js create mode 100644 assets/controllers/fullscreen_controller.ts create mode 100644 assets/controllers/fullscreen_controller_test.ts rename assets/{quiz.js => quiz.ts} (60%) rename assets/{stimulus.js => stimulus.ts} (65%) create mode 100644 assets/types/global.d.ts create mode 100644 deno.json create mode 100644 deno.lock diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 7e5a4de..2a6967e 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -12,7 +12,12 @@ while IFS= read -r file; do [[ -n "$file" ]] && STAGED_TWIG+=("$file") done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true) -if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 ]]; then +STAGED_TS=() +while IFS= read -r file; do + [[ -n "$file" ]] && STAGED_TS+=("$file") +done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.ts$' || true) + +if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 && ${#STAGED_TS[@]} -eq 0 ]]; then exit 0 fi @@ -46,3 +51,18 @@ if [[ ${#STAGED_TWIG[@]} -gt 0 ]]; then "${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}" git add "${STAGED_TWIG[@]}" fi + +if [[ ${#STAGED_TS[@]} -gt 0 ]]; then + echo "TypeScript (${#STAGED_TS[@]} file(s)): Deno fmt → lint → check" + + echo " → Deno fmt" + "${DOCKER_CMD[@]}" deno fmt "${STAGED_TS[@]}" + git add "${STAGED_TS[@]}" + + echo " → Deno lint" + "${DOCKER_CMD[@]}" deno lint --fix "${STAGED_TS[@]}" + git add "${STAGED_TS[@]}" + + echo " → Deno check" + "${DOCKER_CMD[@]}" deno check "${STAGED_TS[@]}" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 917862d..8734e8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,22 @@ jobs: echo "::error::Translations are out of date. Run 'just translations' and commit the changes." exit 1 fi + - name: TypeScript Formatting + id: ts_fmt + continue-on-error: true + run: docker compose exec -T php deno fmt --check assets/ + - name: TypeScript Lint + id: ts_lint + continue-on-error: true + run: docker compose exec -T php deno lint assets/ + - name: TypeScript Type Check + id: ts_check + continue-on-error: true + run: docker compose exec -T php deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts + - name: TypeScript Tests + id: ts_test + continue-on-error: true + run: docker compose exec -T php deno test assets/ - name: Check HTTP reachability run: curl -v --fail-with-body http://localhost - name: Assert all checks passed @@ -123,6 +139,10 @@ jobs: check "PHPStan" "${{ steps.phpstan.outcome }}" check "Rector" "${{ steps.rector.outcome }}" check "Translations" "${{ steps.translations.outcome }}" + check "TypeScript Formatting" "${{ steps.ts_fmt.outcome }}" + check "TypeScript Lint" "${{ steps.ts_lint.outcome }}" + check "TypeScript Type Check" "${{ steps.ts_check.outcome }}" + check "TypeScript Tests" "${{ steps.ts_test.outcome }}" exit $failed tests: diff --git a/CLAUDE.md b/CLAUDE.md index e66b603..30b6cc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ Tech Stack: - **ORM**: Doctrine - **Server**: FrankenPHP with Caddy - **Container**: Docker Compose -- **Frontend**: Twig templates with SASS (via asset mapper) +- **Frontend**: Twig templates with SASS and TypeScript (via asset mapper) - **Testing**: PHPUnit 13 with DAMA Doctrine test bundle ## Build & Development Commands @@ -252,7 +252,8 @@ question counts as covered more than once). - **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes. -- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it. +- For bugs in `assets/*.ts` logic, write a `deno test` first instead — same TDD rule, different runner. Only skip a + test if the bug is in markup/DOM wiring that isn't worth a test per the rule below. - Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists in a template). Tests cover behavior: routing, forms, persistence, authorization. - Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, @@ -276,6 +277,11 @@ question counts as covered more than once). - **Twig-CS-Fixer**: Template style enforcement - **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure — they throw exceptions instead +- **TypeScript (`assets/`)**: Compiled via `sensiolabs/typescript-bundle` (standalone SWC binary, no Node/npm). + Formatting, linting, type-checking, and tests use **Deno** (`deno fmt` / `deno lint` / `deno check` / `deno test`) — + a single standalone binary, kept dev-only (installed in the `frankenphp_dev` Docker stage, not prod), consistent + with the project's no-Node-anywhere approach. See `deno.json` for config; run via `just fix-ts` / `just check-ts` / + `just test-ts`. Tests live alongside their source as `*_test.ts` files ### Environment Configuration diff --git a/Dockerfile b/Dockerfile index 3c20b88..45228a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -62,11 +62,18 @@ ENV APP_ENV=dev XDEBUG_MODE=off # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ bash-completion \ + unzip \ && rm -rf /var/lib/apt/lists/* COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer +# Deno: standalone binary (no Node/npm) used for TypeScript lint/format/type-check/test, +# dev-only tooling so it's not installed in the prod stage. +ENV DENO_INSTALL=/usr/local +# hadolint ignore=DL4006 +RUN curl -fsSL https://deno.land/install.sh | sh -s v2.9.2 + RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" RUN set -eux; \ diff --git a/Justfile b/Justfile index d39e12c..58f4f04 100644 --- a/Justfile +++ b/Justfile @@ -112,6 +112,16 @@ phpstan *args: test *args: docker compose exec php vendor/bin/phpunit {{ args }} +fix-ts: + docker compose exec php deno fmt assets/ + docker compose exec php deno lint --fix assets/ + +check-ts: + docker compose exec php deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts + +test-ts *args: + docker compose exec php deno test assets/ {{ args }} + [confirm] clean: docker compose down -v --remove-orphans diff --git a/assets/backoffice.js b/assets/backoffice.ts similarity index 81% rename from assets/backoffice.js rename to assets/backoffice.ts index af1dcdf..ed72073 100644 --- a/assets/backoffice.js +++ b/assets/backoffice.ts @@ -2,12 +2,15 @@ import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap-icons/font/bootstrap-icons.min.css'; import './styles/backoffice.scss'; import '@hotwired/turbo'; -import './stimulus.js'; -import './bootstrap.js'; +import './stimulus.ts'; +import './bootstrap.ts'; import * as Sentry from '@sentry/browser'; -const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? ''; -const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? ''; +const dsn = document.querySelector('meta[name="sentry-dsn"]') + ?.content ?? ''; +const userEmail = + document.querySelector('meta[name="user-email"]') + ?.content ?? ''; // When no real DSN is configured, route to the local Spotlight sidecar so // nothing reaches Sentry. A syntactically valid DSN is still required for the diff --git a/assets/bootstrap.js b/assets/bootstrap.js deleted file mode 100644 index 786b2ec..0000000 --- a/assets/bootstrap.js +++ /dev/null @@ -1 +0,0 @@ -import * as bootstrap from 'bootstrap' diff --git a/assets/bootstrap.ts b/assets/bootstrap.ts new file mode 100644 index 0000000..abd76b5 --- /dev/null +++ b/assets/bootstrap.ts @@ -0,0 +1 @@ +import 'bootstrap'; diff --git a/assets/controllers/bo/form_collection_controller.js b/assets/controllers/bo/form_collection_controller.js deleted file mode 100644 index e4e973a..0000000 --- a/assets/controllers/bo/form_collection_controller.js +++ /dev/null @@ -1,136 +0,0 @@ -import {Controller} from '@hotwired/stimulus'; - -export default class extends Controller { - static targets = ['collection']; - static values = {prototype: String}; - - connect() { - this.index = this.collectionTarget.children.length; - this._syncOrdering(); - - if (this.index === 0) { - this.addItem(); - } - - // `submit` fires on the ancestor
, which is outside this controller's - // subtree. Stimulus data-action only works within the controller element, so - // addEventListener on the form is the only option here. - this._form = this.element.closest('form'); - if (this._form) { - this._submitHandler = () => { - [...this.collectionTarget.children].forEach(item => { - const input = item.querySelector('input[type="text"]'); - if (input && input.value.trim() === '') item.remove(); - }); - }; - this._form.addEventListener('submit', this._submitHandler); - } - } - - disconnect() { - if (this._form && this._submitHandler) { - this._form.removeEventListener('submit', this._submitHandler); - } - } - - addItem() { - const item = document.createElement('div'); - item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index); - const el = item.firstElementChild; - this.collectionTarget.appendChild(el); - this.index++; - this._syncOrdering(); - } - - removeItem(event) { - event.target.closest('[data-collection-item]').remove(); - this._notifyChange(); - } - - sortAlphabetically() { - const items = [...this.collectionTarget.children]; - items.sort((a, b) => { - const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase(); - const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase(); - return textA.localeCompare(textB); - }); - items.forEach(item => this.collectionTarget.appendChild(item)); - this._syncOrdering(); - this._notifyChange(); - } - - randomize() { - const items = [...this.collectionTarget.children]; - for (let i = items.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [items[i], items[j]] = [items[j], items[i]]; - } - items.forEach(item => this.collectionTarget.appendChild(item)); - this._syncOrdering(); - this._notifyChange(); - } - - autoExpand(event) { - if (event.target.type !== 'text') return; - const item = event.target.closest('[data-collection-item]'); - const last = [...this.collectionTarget.children].at(-1); - if (item && item === last && event.target.value.trim() !== '') { - this.addItem(); - } - } - - // — drag-and-drop — - - dragStart(event) { - this._dragging = event.currentTarget.closest('[data-collection-item]'); - this._dragging.classList.add('opacity-50'); - event.dataTransfer.effectAllowed = 'move'; - } - - dragEnd(event) { - event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50'); - this._dragging = null; - this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => - i.classList.remove('border-top', 'border-bottom', 'border-primary'), - ); - } - - dragOver(event) { - event.preventDefault(); - const el = event.currentTarget; - if (!this._dragging || this._dragging === el) return; - event.dataTransfer.dropEffect = 'move'; - const rect = el.getBoundingClientRect(); - const isBottom = event.clientY > rect.top + rect.height / 2; - el.classList.toggle('border-top', !isBottom); - el.classList.toggle('border-bottom', isBottom); - el.classList.add('border-primary'); - } - - dragLeave(event) { - event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary'); - } - - drop(event) { - event.preventDefault(); - const el = event.currentTarget; - el.classList.remove('border-top', 'border-bottom', 'border-primary'); - if (!this._dragging || this._dragging === el) return; - const rect = el.getBoundingClientRect(); - const isBottom = event.clientY > rect.top + rect.height / 2; - this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el); - this._syncOrdering(); - this._notifyChange(); - } - - _notifyChange() { - this.element.dispatchEvent(new Event('change', {bubbles: true})); - } - - _syncOrdering() { - [...this.collectionTarget.children].forEach((el, i) => { - const input = el.querySelector('input[name*="[ordering]"]'); - if (input) input.value = i; - }); - } -} diff --git a/assets/controllers/bo/form_collection_controller.ts b/assets/controllers/bo/form_collection_controller.ts new file mode 100644 index 0000000..75f7d0e --- /dev/null +++ b/assets/controllers/bo/form_collection_controller.ts @@ -0,0 +1,172 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['collection']; + static values = { prototype: String }; + + declare readonly collectionTarget: HTMLElement; + declare readonly prototypeValue: string; + + index = 0; + _dragging: Element | null = null; + _form: HTMLFormElement | null = null; + _submitHandler: (() => void) | null = null; + + connect(): void { + this.index = this.collectionTarget.children.length; + this._syncOrdering(); + + if (this.index === 0) { + this.addItem(); + } + + // `submit` fires on the ancestor , which is outside this controller's + // subtree. Stimulus data-action only works within the controller element, so + // addEventListener on the form is the only option here. + this._form = this.element.closest('form'); + if (this._form) { + this._submitHandler = () => { + [...this.collectionTarget.children].forEach((item) => { + const input = item.querySelector( + 'input[type="text"]', + ); + if (input && input.value.trim() === '') item.remove(); + }); + }; + this._form.addEventListener('submit', this._submitHandler); + } + } + + disconnect(): void { + if (this._form && this._submitHandler) { + this._form.removeEventListener('submit', this._submitHandler); + } + } + + addItem(): void { + const item = document.createElement('div'); + item.innerHTML = this.prototypeValue.replace( + /__name__/g, + String(this.index), + ); + const el = item.firstElementChild; + if (el) this.collectionTarget.appendChild(el); + this.index++; + this._syncOrdering(); + } + + removeItem(event: Event): void { + (event.target as HTMLElement).closest('[data-collection-item]') + ?.remove(); + this._notifyChange(); + } + + sortAlphabetically(): void { + const items = [...this.collectionTarget.children]; + items.sort((a, b) => { + const textA = + (a.querySelector('input[type="text"]') + ?.value ?? '').toLowerCase(); + const textB = + (b.querySelector('input[type="text"]') + ?.value ?? '').toLowerCase(); + return textA.localeCompare(textB); + }); + items.forEach((item) => this.collectionTarget.appendChild(item)); + this._syncOrdering(); + this._notifyChange(); + } + + randomize(): void { + const items = [...this.collectionTarget.children]; + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + items.forEach((item) => this.collectionTarget.appendChild(item)); + this._syncOrdering(); + this._notifyChange(); + } + + autoExpand(event: Event): void { + const target = event.target as HTMLInputElement; + if (target.type !== 'text') return; + const item = target.closest('[data-collection-item]'); + const last = [...this.collectionTarget.children].at(-1); + if (item && item === last && target.value.trim() !== '') { + this.addItem(); + } + } + + // — drag-and-drop — + + dragStart(event: DragEvent): void { + this._dragging = (event.currentTarget as HTMLElement).closest( + '[data-collection-item]', + ); + this._dragging?.classList.add('opacity-50'); + event.dataTransfer!.effectAllowed = 'move'; + } + + dragEnd(event: DragEvent): void { + (event.currentTarget as HTMLElement).closest('[data-collection-item]') + ?.classList.remove('opacity-50'); + this._dragging = null; + this.collectionTarget.querySelectorAll('[data-collection-item]') + .forEach((i) => + i.classList.remove( + 'border-top', + 'border-bottom', + 'border-primary', + ) + ); + } + + dragOver(event: DragEvent): void { + event.preventDefault(); + const el = event.currentTarget as HTMLElement; + if (!this._dragging || this._dragging === el) return; + event.dataTransfer!.dropEffect = 'move'; + const rect = el.getBoundingClientRect(); + const isBottom = event.clientY > rect.top + rect.height / 2; + el.classList.toggle('border-top', !isBottom); + el.classList.toggle('border-bottom', isBottom); + el.classList.add('border-primary'); + } + + dragLeave(event: DragEvent): void { + (event.currentTarget as HTMLElement).classList.remove( + 'border-top', + 'border-bottom', + 'border-primary', + ); + } + + drop(event: DragEvent): void { + event.preventDefault(); + const el = event.currentTarget as HTMLElement; + el.classList.remove('border-top', 'border-bottom', 'border-primary'); + if (!this._dragging || this._dragging === el) return; + const rect = el.getBoundingClientRect(); + const isBottom = event.clientY > rect.top + rect.height / 2; + this.collectionTarget.insertBefore( + this._dragging, + isBottom ? el.nextSibling : el, + ); + this._syncOrdering(); + this._notifyChange(); + } + + _notifyChange(): void { + this.element.dispatchEvent(new Event('change', { bubbles: true })); + } + + _syncOrdering(): void { + [...this.collectionTarget.children].forEach((el, i) => { + const input = el.querySelector( + 'input[name*="[ordering]"]', + ); + if (input) input.value = String(i); + }); + } +} diff --git a/assets/controllers/bo/form_collection_controller_test.ts b/assets/controllers/bo/form_collection_controller_test.ts new file mode 100644 index 0000000..814fd16 --- /dev/null +++ b/assets/controllers/bo/form_collection_controller_test.ts @@ -0,0 +1,114 @@ +import { assertEquals } from '@std/assert'; + +// Stimulus's Controller base class constructor only does `this.context = context`, +// and target getters are normally installed by Application.register — since we +// construct the controller directly (no real Stimulus app), collectionTarget/element +// are assigned here as plain writable properties, backed by a minimal fake DOM +// container that supports the one operation these methods actually rely on: +// appendChild() moving an existing child to the end (real DOM semantics). +class FakeInput { + value: string; + constructor(value = '') { + this.value = value; + } +} + +class FakeItem { + textInput: FakeInput; + orderingInput = new FakeInput(); + + constructor(text: string) { + this.textInput = new FakeInput(text); + } + + querySelector(selector: string): T | null { + if (selector.includes('type="text"')) { + return this.textInput as unknown as T; + } + if (selector.includes('ordering')) { + return this.orderingInput as unknown as T; + } + return null; + } +} + +class FakeCollection { + children: FakeItem[] = []; + + appendChild(item: FakeItem) { + const idx = this.children.indexOf(item); + if (idx !== -1) this.children.splice(idx, 1); + this.children.push(item); + } +} + +const { default: FormCollectionController } = await import( + './form_collection_controller.ts' +); + +// deno-lint-ignore no-explicit-any +function makeController(items: FakeItem[]): any { + const collectionTarget = new FakeCollection(); + collectionTarget.children = items; + // `element` is a read-only getter on Controller (delegates to `this.scope.element`), + // so the fake element is supplied via the constructor context rather than assigned. + const controller = new FormCollectionController({ + scope: { element: { dispatchEvent: () => true } }, + } as never); + return Object.assign(controller, { collectionTarget }); +} + +Deno.test("_syncOrdering writes the current index into each item's ordering input", () => { + const items = [new FakeItem('c'), new FakeItem('a'), new FakeItem('b')]; + const controller = makeController(items); + + controller._syncOrdering(); + + assertEquals(items.map((i) => i.orderingInput.value), ['0', '1', '2']); +}); + +Deno.test('sortAlphabetically reorders items by their text input value, case-insensitively', () => { + const c = new FakeItem('Charlie'); + const a = new FakeItem('alice'); + const b = new FakeItem('Bob'); + const controller = makeController([c, a, b]); + + controller.sortAlphabetically(); + + assertEquals( + controller.collectionTarget.children.map((i: FakeItem) => + i.textInput.value + ), + ['alice', 'Bob', 'Charlie'], + ); + // _syncOrdering must run after the reorder, against the new order + assertEquals( + controller.collectionTarget.children.map((i: FakeItem) => + i.orderingInput.value + ), + ['0', '1', '2'], + ); +}); + +Deno.test('randomize keeps the same set of items and resyncs ordering', () => { + const items = [ + new FakeItem('1'), + new FakeItem('2'), + new FakeItem('3'), + new FakeItem('4'), + ]; + const controller = makeController([...items]); + + controller.randomize(); + + const resultValues = controller.collectionTarget.children.map(( + i: FakeItem, + ) => i.textInput.value); + assertEquals(resultValues.slice().sort(), ['1', '2', '3', '4']); + assertEquals( + controller.collectionTarget.children.map((i: FakeItem) => + i.orderingInput.value + ), + ['0', '1', '2', '3'], + ); +}); diff --git a/assets/controllers/bo/modal_controller.js b/assets/controllers/bo/modal_controller.js deleted file mode 100644 index 543d5df..0000000 --- a/assets/controllers/bo/modal_controller.js +++ /dev/null @@ -1,49 +0,0 @@ -import {Controller} from '@hotwired/stimulus'; -import {Modal} from 'bootstrap'; -import {visit} from '@hotwired/turbo'; - -export default class extends Controller { - static targets = ['modal', 'frame']; - - open(event) { - event.preventDefault(); - const {src, modalTitle} = event.currentTarget.dataset; - if (modalTitle) { - const titleEl = this.modalTarget.querySelector('.modal-title'); - if (titleEl) titleEl.textContent = modalTitle; - } - this.resetDirty(); - this.frameTarget.innerHTML = ''; - this.frameTarget.removeAttribute('src'); - this.frameTarget.setAttribute('src', src); - Modal.getOrCreateInstance(this.modalTarget).show(); - } - - frameSubmitEnd(event) { - if (event.detail.success) { - Modal.getOrCreateInstance(this.modalTarget).hide(); - visit(window.location.href); - } - } - - markDirty() { - if (this._dirty) return; - this._dirty = true; - // Using _config instead of preventDefault on hide.bs.modal because we need - // to block only user-triggered dismissal (backdrop click, Escape key) while - // keeping programmatic hide() working — frameSubmitEnd() calls hide() after - // a successful save and must not be blocked. _config.backdrop/keyboard is - // the correct primitive for that distinction and has been stable across all - // Bootstrap 5.x releases. - const modal = Modal.getOrCreateInstance(this.modalTarget); - modal._config.backdrop = 'static'; - modal._config.keyboard = false; - } - - resetDirty() { - this._dirty = false; - const modal = Modal.getOrCreateInstance(this.modalTarget); - modal._config.backdrop = true; - modal._config.keyboard = true; - } -} diff --git a/assets/controllers/bo/modal_controller.ts b/assets/controllers/bo/modal_controller.ts new file mode 100644 index 0000000..b1b1a27 --- /dev/null +++ b/assets/controllers/bo/modal_controller.ts @@ -0,0 +1,66 @@ +import { Controller } from '@hotwired/stimulus'; +import { Modal } from 'bootstrap'; +import { visit } from '@hotwired/turbo'; + +// Bootstrap's public Modal type doesn't expose `_config` (an internal, but stable, field +// across all 5.x releases) — extend it locally rather than casting to `any` everywhere. +interface ModalWithConfig extends Modal { + _config: { backdrop: boolean | 'static'; keyboard: boolean }; +} + +export default class extends Controller { + static targets = ['modal', 'frame']; + + declare readonly modalTarget: HTMLElement; + declare readonly frameTarget: HTMLElement; + + _dirty = false; + + open(event: MouseEvent): void { + event.preventDefault(); + const { src, modalTitle } = + (event.currentTarget as HTMLElement).dataset; + if (modalTitle) { + const titleEl = this.modalTarget.querySelector('.modal-title'); + if (titleEl) titleEl.textContent = modalTitle; + } + this.resetDirty(); + this.frameTarget.innerHTML = + ''; + this.frameTarget.removeAttribute('src'); + if (src) this.frameTarget.setAttribute('src', src); + Modal.getOrCreateInstance(this.modalTarget).show(); + } + + frameSubmitEnd(event: CustomEvent<{ success: boolean }>): void { + if (event.detail.success) { + Modal.getOrCreateInstance(this.modalTarget).hide(); + visit(window.location.href); + } + } + + markDirty(): void { + if (this._dirty) return; + this._dirty = true; + // Using _config instead of preventDefault on hide.bs.modal because we need + // to block only user-triggered dismissal (backdrop click, Escape key) while + // keeping programmatic hide() working — frameSubmitEnd() calls hide() after + // a successful save and must not be blocked. _config.backdrop/keyboard is + // the correct primitive for that distinction and has been stable across all + // Bootstrap 5.x releases. + const modal = Modal.getOrCreateInstance( + this.modalTarget, + ) as ModalWithConfig; + modal._config.backdrop = 'static'; + modal._config.keyboard = false; + } + + resetDirty(): void { + this._dirty = false; + const modal = Modal.getOrCreateInstance( + this.modalTarget, + ) as ModalWithConfig; + modal._config.backdrop = true; + modal._config.keyboard = true; + } +} diff --git a/assets/controllers/bo/popover_controller.js b/assets/controllers/bo/popover_controller.js deleted file mode 100644 index f0b254d..0000000 --- a/assets/controllers/bo/popover_controller.js +++ /dev/null @@ -1,13 +0,0 @@ -import {Controller} from '@hotwired/stimulus'; -import {Popover} from 'bootstrap'; - -export default class extends Controller { - connect() { - this.popovers = [...this.element.querySelectorAll('[data-bs-toggle="popover"]')] - .map(popoverTriggerEl => Popover.getOrCreateInstance(popoverTriggerEl)); - } - - disconnect() { - this.popovers.forEach(popover => popover.dispose()); - } -} diff --git a/assets/controllers/bo/popover_controller.ts b/assets/controllers/bo/popover_controller.ts new file mode 100644 index 0000000..1c936dd --- /dev/null +++ b/assets/controllers/bo/popover_controller.ts @@ -0,0 +1,19 @@ +import { Controller } from '@hotwired/stimulus'; +import { Popover } from 'bootstrap'; + +export default class extends Controller { + popovers: Popover[] = []; + + connect(): void { + this.popovers = [ + ...this.element.querySelectorAll('[data-bs-toggle="popover"]'), + ] + .map((popoverTriggerEl) => + Popover.getOrCreateInstance(popoverTriggerEl) + ); + } + + disconnect(): void { + this.popovers.forEach((popover) => popover.dispose()); + } +} diff --git a/assets/controllers/bo/question_list_controller.js b/assets/controllers/bo/question_list_controller.js deleted file mode 100644 index 294cf18..0000000 --- a/assets/controllers/bo/question_list_controller.js +++ /dev/null @@ -1,123 +0,0 @@ -import {Controller} from '@hotwired/stimulus'; - -export default class extends Controller { - static targets = ['list', 'item', 'status']; - static values = { - reorderUrl: String, - csrf: String, - savedLabel: String, - errorLabel: String, - errorHint: String, - }; - - connect() { - this._locked = false; - } - - dragStart(event) { - const item = event.currentTarget.closest('[data-bo--question-list-target="item"]'); - this._dragging = item; - event.dataTransfer.effectAllowed = 'move'; - setTimeout(() => item.classList.add('opacity-50'), 0); - } - - dragEnd(event) { - const item = event.currentTarget.closest('[data-bo--question-list-target="item"]'); - item.classList.remove('opacity-50'); - this._dragging = null; - this._removePlaceholder(); - } - - dragOver(event) { - event.preventDefault(); - if (!this._dragging) return; - event.dataTransfer.dropEffect = 'move'; - - const target = event.target.closest('[data-bo--question-list-target="item"]'); - if (!target || target === this._dragging) return; - - const rect = target.getBoundingClientRect(); - const insertBefore = event.clientY > rect.top + rect.height / 2 ? target.nextSibling : target; - - if (!this._placeholder) { - this._placeholder = document.createElement('div'); - this._placeholder.className = 'bg-primary rounded mb-2'; - this._placeholder.style.height = '3px'; - } - - if (this._placeholder.nextSibling !== insertBefore) { - this.listTarget.insertBefore(this._placeholder, insertBefore); - } - } - - dragLeave(event) { - if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) { - this._removePlaceholder(); - } - } - - async drop(event) { - event.preventDefault(); - if (!this._dragging || !this._placeholder || this._locked) return; - this.listTarget.insertBefore(this._dragging, this._placeholder); - this._removePlaceholder(); - await this._persistOrder(); - } - - _removePlaceholder() { - if (this._placeholder) { - this._placeholder.remove(); - this._placeholder = null; - } - } - - _setStatus(state) { - if (!this.hasStatusTarget) return; - const el = this.statusTarget; - el.classList.remove('d-none', 'text-bg-success', 'text-bg-danger', 'text-bg-warning'); - if (state === 'saving') { - el.classList.add('text-bg-warning'); - el.textContent = '…'; - } else if (state === 'saved') { - el.classList.add('text-bg-success'); - el.textContent = this.savedLabelValue || 'Saved'; - } else if (state === 'error') { - el.classList.add('text-bg-danger'); - el.textContent = this.errorLabelValue || 'Error'; - } - } - - async _persistOrder() { - this._setStatus('saving'); - - const params = new URLSearchParams(); - params.append('_token', this.csrfValue); - this.itemTargets.forEach((el, i) => { - params.append('ordering[]', el.dataset.questionId); - const numberEl = el.querySelector('[data-question-number]'); - if (numberEl) numberEl.textContent = String(i + 1); - }); - - for (let attempt = 0; attempt < 2; attempt++) { - try { - const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params}); - if (res.ok) { - this._setStatus('saved'); - return; - } - } catch { - // network error — retry on first attempt - } - } - - this._locked = true; - this._setStatus('error'); - - const alert = document.createElement('div'); - alert.className = 'alert alert-danger alert-dismissible mt-3'; - alert.setAttribute('role', 'alert'); - const hint = this.errorHintValue || 'Refresh the page to try again.'; - alert.innerHTML = `${this.errorLabelValue || 'Error saving order'} — ${hint} `; - this.listTarget.after(alert); - } -} diff --git a/assets/controllers/bo/question_list_controller.ts b/assets/controllers/bo/question_list_controller.ts new file mode 100644 index 0000000..83fd6c5 --- /dev/null +++ b/assets/controllers/bo/question_list_controller.ts @@ -0,0 +1,161 @@ +import { Controller } from '@hotwired/stimulus'; + +type Status = 'saving' | 'saved' | 'error'; + +export default class extends Controller { + static targets = ['list', 'item', 'status']; + static values = { + reorderUrl: String, + csrf: String, + savedLabel: String, + errorLabel: String, + errorHint: String, + }; + + declare readonly listTarget: HTMLElement; + declare readonly itemTargets: HTMLElement[]; + declare readonly statusTarget: HTMLElement; + declare readonly hasStatusTarget: boolean; + + declare readonly reorderUrlValue: string; + declare readonly csrfValue: string; + declare readonly savedLabelValue: string; + declare readonly errorLabelValue: string; + declare readonly errorHintValue: string; + + _locked = false; + _dragging: HTMLElement | null = null; + _placeholder: HTMLDivElement | null = null; + + connect(): void { + this._locked = false; + } + + dragStart(event: DragEvent): void { + const item = (event.currentTarget as HTMLElement).closest( + '[data-bo--question-list-target="item"]', + ); + this._dragging = item; + event.dataTransfer!.effectAllowed = 'move'; + setTimeout(() => item?.classList.add('opacity-50'), 0); + } + + dragEnd(event: DragEvent): void { + const item = (event.currentTarget as HTMLElement).closest( + '[data-bo--question-list-target="item"]', + ); + item?.classList.remove('opacity-50'); + this._dragging = null; + this._removePlaceholder(); + } + + dragOver(event: DragEvent): void { + event.preventDefault(); + if (!this._dragging) return; + event.dataTransfer!.dropEffect = 'move'; + + const target = (event.target as HTMLElement).closest( + '[data-bo--question-list-target="item"]', + ); + if (!target || target === this._dragging) return; + + const rect = target.getBoundingClientRect(); + const insertBefore = event.clientY > rect.top + rect.height / 2 + ? target.nextSibling + : target; + + if (!this._placeholder) { + this._placeholder = document.createElement('div'); + this._placeholder.className = 'bg-primary rounded mb-2'; + this._placeholder.style.height = '3px'; + } + + if (this._placeholder.nextSibling !== insertBefore) { + this.listTarget.insertBefore(this._placeholder, insertBefore); + } + } + + dragLeave(event: DragEvent): void { + if ( + !event.relatedTarget || + !this.listTarget.contains(event.relatedTarget as Node) + ) { + this._removePlaceholder(); + } + } + + async drop(event: DragEvent): Promise { + event.preventDefault(); + if (!this._dragging || !this._placeholder || this._locked) return; + this.listTarget.insertBefore(this._dragging, this._placeholder); + this._removePlaceholder(); + await this._persistOrder(); + } + + _removePlaceholder(): void { + if (this._placeholder) { + this._placeholder.remove(); + this._placeholder = null; + } + } + + _setStatus(state: Status): void { + if (!this.hasStatusTarget) return; + const el = this.statusTarget; + el.classList.remove( + 'd-none', + 'text-bg-success', + 'text-bg-danger', + 'text-bg-warning', + ); + if (state === 'saving') { + el.classList.add('text-bg-warning'); + el.textContent = '…'; + } else if (state === 'saved') { + el.classList.add('text-bg-success'); + el.textContent = this.savedLabelValue || 'Saved'; + } else if (state === 'error') { + el.classList.add('text-bg-danger'); + el.textContent = this.errorLabelValue || 'Error'; + } + } + + async _persistOrder(): Promise { + this._setStatus('saving'); + + const params = new URLSearchParams(); + params.append('_token', this.csrfValue); + this.itemTargets.forEach((el, i) => { + params.append('ordering[]', el.dataset.questionId ?? ''); + const numberEl = el.querySelector('[data-question-number]'); + if (numberEl) numberEl.textContent = String(i + 1); + }); + + for (let attempt = 0; attempt < 2; attempt++) { + try { + const res = await fetch(this.reorderUrlValue, { + method: 'POST', + body: params, + }); + if (res.ok) { + this._setStatus('saved'); + return; + } + } catch { + // network error — retry on first attempt + } + } + + this._locked = true; + this._setStatus('error'); + + const alert = document.createElement('div'); + alert.className = 'alert alert-danger alert-dismissible mt-3'; + alert.setAttribute('role', 'alert'); + const hint = this.errorHintValue || 'Refresh the page to try again.'; + alert.innerHTML = `${ + this.errorLabelValue || 'Error saving order' + } — ${hint} `; + this.listTarget.after(alert); + } +} diff --git a/assets/controllers/bo/question_list_controller_test.ts b/assets/controllers/bo/question_list_controller_test.ts new file mode 100644 index 0000000..9858f5f --- /dev/null +++ b/assets/controllers/bo/question_list_controller_test.ts @@ -0,0 +1,140 @@ +import { assertEquals } from '@std/assert'; + +// Stimulus's Controller base class constructor only does `this.context = context`, +// and target/value getters (listTarget, csrfValue, ...) are normally installed by +// Application.register — since we construct the controller directly (no real +// Stimulus app), they're assigned here as plain writable properties instead. +class FakeClassList { + classes = new Set(); + add(...names: string[]) { + names.forEach((n) => this.classes.add(n)); + } + remove(...names: string[]) { + names.forEach((n) => this.classes.delete(n)); + } + contains(name: string) { + return this.classes.has(name); + } +} + +function fakeElement() { + return { + classList: new FakeClassList(), + textContent: '', + after: () => {}, + setAttribute: () => {}, + }; +} + +// deno-lint-ignore no-explicit-any +(globalThis as any).document = { + createElement: () => ({ + ...fakeElement(), + innerHTML: '', + }), +}; + +const { default: QuestionListController } = await import( + './question_list_controller.ts' +); + +// deno-lint-ignore no-explicit-any +function makeController(overrides: Record = {}): any { + const controller = new QuestionListController({} as never); + return Object.assign(controller, { + listTarget: fakeElement(), + itemTargets: [], + hasStatusTarget: true, + statusTarget: fakeElement(), + csrfValue: 'csrf-token', + reorderUrlValue: '/reorder', + savedLabelValue: 'Saved', + errorLabelValue: 'Error', + errorHintValue: 'Refresh the page.', + ...overrides, + }); +} + +Deno.test('_setStatus renders the saving state', () => { + const controller = makeController(); + controller._setStatus('saving'); + assertEquals( + controller.statusTarget.classList.contains('text-bg-warning'), + true, + ); + assertEquals(controller.statusTarget.textContent, '…'); +}); + +Deno.test('_setStatus renders the saved state with the configured label', () => { + const controller = makeController(); + controller._setStatus('saved'); + assertEquals( + controller.statusTarget.classList.contains('text-bg-success'), + true, + ); + assertEquals(controller.statusTarget.textContent, 'Saved'); +}); + +Deno.test('_setStatus is a no-op when there is no status target', () => { + const controller = makeController({ hasStatusTarget: false }); + controller._setStatus('saving'); + assertEquals(controller.statusTarget.textContent, ''); +}); + +Deno.test('_persistOrder posts the item ordering and reports success', async () => { + const numberEl = { textContent: '' }; + const item = { + dataset: { questionId: '42' }, + querySelector: () => numberEl, + }; + const controller = makeController({ itemTargets: [item] }); + + let requestBody: URLSearchParams | undefined; + globalThis.fetch = ((_url: string, init: RequestInit) => { + requestBody = init.body as URLSearchParams; + return Promise.resolve({ ok: true } as Response); + }) as typeof fetch; + + await controller._persistOrder(); + + assertEquals(controller._locked, false); + assertEquals( + controller.statusTarget.classList.contains('text-bg-success'), + true, + ); + assertEquals(numberEl.textContent, '1'); + assertEquals(requestBody?.get('_token'), 'csrf-token'); + assertEquals(requestBody?.get('ordering[]'), '42'); +}); + +Deno.test('_persistOrder retries once, then locks and shows an error after repeated failure', async () => { + const controller = makeController(); + let calls = 0; + globalThis.fetch = (() => { + calls++; + return Promise.resolve({ ok: false } as Response); + }) as typeof fetch; + + await controller._persistOrder(); + + assertEquals(calls, 2); + assertEquals(controller._locked, true); + assertEquals( + controller.statusTarget.classList.contains('text-bg-danger'), + true, + ); +}); + +Deno.test('_persistOrder treats a network error the same as a failed response', async () => { + const controller = makeController(); + globalThis.fetch = + (() => Promise.reject(new Error('network down'))) as typeof fetch; + + await controller._persistOrder(); + + assertEquals(controller._locked, true); + assertEquals( + controller.statusTarget.classList.contains('text-bg-danger'), + true, + ); +}); diff --git a/assets/controllers/bo/quiz_controller.js b/assets/controllers/bo/quiz_controller.js deleted file mode 100644 index 3db64f3..0000000 --- a/assets/controllers/bo/quiz_controller.js +++ /dev/null @@ -1,28 +0,0 @@ -import {Controller} from '@hotwired/stimulus'; -import {Tooltip, Modal} from 'bootstrap'; - -export default class extends Controller { - static targets = ['clearModal', 'deleteModal']; - - connect() { - this.tooltips = []; - const tooltipTriggerList = this.element.querySelectorAll('[data-bs-toggle="tooltip"]'); - [...tooltipTriggerList].forEach(tooltipTriggerEl => { - this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl)); - }); - } - - disconnect() { - this.tooltips.forEach(tooltip => tooltip.dispose()); - } - - clearQuiz() { - const modal = Modal.getOrCreateInstance(this.clearModalTarget); - modal.show(); - } - - deleteQuiz() { - const modal = Modal.getOrCreateInstance(this.deleteModalTarget); - modal.show(); - } -} diff --git a/assets/controllers/bo/quiz_controller.ts b/assets/controllers/bo/quiz_controller.ts new file mode 100644 index 0000000..25d1c44 --- /dev/null +++ b/assets/controllers/bo/quiz_controller.ts @@ -0,0 +1,35 @@ +import { Controller } from '@hotwired/stimulus'; +import { Modal, Tooltip } from 'bootstrap'; + +export default class extends Controller { + static targets = ['clearModal', 'deleteModal']; + + declare readonly clearModalTarget: HTMLElement; + declare readonly deleteModalTarget: HTMLElement; + + tooltips: Tooltip[] = []; + + connect(): void { + this.tooltips = []; + const tooltipTriggerList = this.element.querySelectorAll( + '[data-bs-toggle="tooltip"]', + ); + [...tooltipTriggerList].forEach((tooltipTriggerEl) => { + this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl)); + }); + } + + disconnect(): void { + this.tooltips.forEach((tooltip) => tooltip.dispose()); + } + + clearQuiz(): void { + const modal = Modal.getOrCreateInstance(this.clearModalTarget); + modal.show(); + } + + deleteQuiz(): void { + const modal = Modal.getOrCreateInstance(this.deleteModalTarget); + modal.show(); + } +} diff --git a/assets/controllers/csrf_protection_controller.js b/assets/controllers/csrf_protection_controller.js deleted file mode 100644 index 511fffa..0000000 --- a/assets/controllers/csrf_protection_controller.js +++ /dev/null @@ -1,81 +0,0 @@ -const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/; -const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/; - -// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager -// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event -// and thus this event-listener will not be executed. -document.addEventListener('submit', function (event) { - generateCsrfToken(event.target); -}, true); - -// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie -// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked -document.addEventListener('turbo:submit-start', function (event) { - const h = generateCsrfHeaders(event.detail.formSubmission.formElement); - Object.keys(h).map(function (k) { - event.detail.formSubmission.fetchRequest.headers[k] = h[k]; - }); -}); - -// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted -document.addEventListener('turbo:submit-end', function (event) { - removeCsrfToken(event.detail.formSubmission.formElement); -}); - -export function generateCsrfToken (formElement) { - const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); - - if (!csrfField) { - return; - } - - let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); - let csrfToken = csrfField.value; - - if (!csrfCookie && nameCheck.test(csrfToken)) { - csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken); - csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18)))); - } - csrfField.dispatchEvent(new Event('change', { bubbles: true })); - - if (csrfCookie && tokenCheck.test(csrfToken)) { - const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict'; - document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; - } -} - -export function generateCsrfHeaders (formElement) { - const headers = {}; - const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); - - if (!csrfField) { - return headers; - } - - const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); - - if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { - headers[csrfCookie] = csrfField.value; - } - - return headers; -} - -export function removeCsrfToken (formElement) { - const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]'); - - if (!csrfField) { - return; - } - - const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value'); - - if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) { - const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0'; - - document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie; - } -} - -/* stimulusFetch: 'lazy' */ -export default 'csrf-protection-controller'; diff --git a/assets/controllers/csrf_protection_controller.ts b/assets/controllers/csrf_protection_controller.ts new file mode 100644 index 0000000..c141d97 --- /dev/null +++ b/assets/controllers/csrf_protection_controller.ts @@ -0,0 +1,129 @@ +const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/; +const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/; + +interface TurboSubmitEventDetail { + formSubmission: { + formElement: HTMLFormElement; + fetchRequest: { headers: Record }; + }; +} + +const CSRF_FIELD_SELECTOR = + 'input[data-controller="csrf-protection"], input[name="_csrf_token"]'; + +// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager +// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event +// and thus this event-listener will not be executed. +document.addEventListener('submit', function (event) { + generateCsrfToken(event.target as HTMLFormElement); +}, true); + +// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie +// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked +document.addEventListener('turbo:submit-start', function (event) { + const detail = (event as CustomEvent).detail; + const h = generateCsrfHeaders(detail.formSubmission.formElement); + Object.keys(h).forEach(function (k) { + detail.formSubmission.fetchRequest.headers[k] = h[k]; + }); +}); + +// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted +document.addEventListener('turbo:submit-end', function (event) { + const detail = (event as CustomEvent).detail; + removeCsrfToken(detail.formSubmission.formElement); +}); + +export function generateCsrfToken(formElement: HTMLFormElement): void { + const csrfField = formElement.querySelector( + CSRF_FIELD_SELECTOR, + ); + + if (!csrfField) { + return; + } + + let csrfCookie = csrfField.getAttribute( + 'data-csrf-protection-cookie-value', + ); + let csrfToken = csrfField.value; + + if (!csrfCookie && nameCheck.test(csrfToken)) { + csrfField.setAttribute( + 'data-csrf-protection-cookie-value', + csrfCookie = csrfToken, + ); + csrfField.defaultValue = csrfToken = btoa( + String.fromCharCode.apply( + null, + Array.from( + (window.crypto || + (window as unknown as { msCrypto: Crypto }).msCrypto) + .getRandomValues(new Uint8Array(18)), + ), + ), + ); + } + csrfField.dispatchEvent(new Event('change', { bubbles: true })); + + if (csrfCookie && tokenCheck.test(csrfToken)) { + const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + + '; path=/; samesite=strict'; + document.cookie = window.location.protocol === 'https:' + ? '__Host-' + cookie + '; secure' + : cookie; + } +} + +export function generateCsrfHeaders( + formElement: HTMLFormElement, +): Record { + const headers: Record = {}; + const csrfField = formElement.querySelector( + CSRF_FIELD_SELECTOR, + ); + + if (!csrfField) { + return headers; + } + + const csrfCookie = csrfField.getAttribute( + 'data-csrf-protection-cookie-value', + ); + + if ( + tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie)) + ) { + headers[String(csrfCookie)] = csrfField.value; + } + + return headers; +} + +export function removeCsrfToken(formElement: HTMLFormElement): void { + const csrfField = formElement.querySelector( + CSRF_FIELD_SELECTOR, + ); + + if (!csrfField) { + return; + } + + const csrfCookie = csrfField.getAttribute( + 'data-csrf-protection-cookie-value', + ); + + if ( + tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie)) + ) { + const cookie = String(csrfCookie) + '_' + csrfField.value + + '=0; path=/; samesite=strict; max-age=0'; + + document.cookie = window.location.protocol === 'https:' + ? '__Host-' + cookie + '; secure' + : cookie; + } +} + +/* stimulusFetch: 'lazy' */ +export default 'csrf-protection-controller'; diff --git a/assets/controllers/csrf_protection_controller_test.ts b/assets/controllers/csrf_protection_controller_test.ts new file mode 100644 index 0000000..c5b105c --- /dev/null +++ b/assets/controllers/csrf_protection_controller_test.ts @@ -0,0 +1,119 @@ +import { assertEquals, assertMatch } from '@std/assert'; + +// The module under test registers `document.addEventListener(...)` calls at +// import time and reads `window.location`/`window.crypto`, so a minimal +// `document`/`window` stub must exist in the global scope *before* the module +// is imported. Deno has no browser DOM, and the actual surface these +// functions touch on a form/input is narrow (getAttribute/setAttribute/value/ +// dispatchEvent), so hand-built fakes are used instead of a full DOM polyfill. +let cookieJar = ''; +// deno-lint-ignore no-explicit-any +(globalThis as any).document = { + addEventListener: () => {}, + get cookie() { + return cookieJar; + }, + set cookie(value: string) { + cookieJar += (cookieJar ? '; ' : '') + value; + }, +}; +// deno-lint-ignore no-explicit-any +(globalThis as any).window = { + location: { protocol: 'http:' }, + crypto: globalThis.crypto, +}; + +const { generateCsrfHeaders, generateCsrfToken, removeCsrfToken } = + await import( + './csrf_protection_controller.ts' + ); + +function fakeField(attributes: Record = {}, value = '') { + const store = new Map(Object.entries(attributes)); + return { + getAttribute: (name: string) => store.get(name) ?? null, + setAttribute: (name: string, val: string) => { + store.set(name, val); + }, + value, + defaultValue: value, + dispatchEvent: () => true, + }; +} + +// deno-lint-ignore no-explicit-any +function fakeForm(field: ReturnType | null): any { + return { querySelector: () => field }; +} + +Deno.test('generateCsrfToken does nothing when the form has no csrf field', () => { + cookieJar = ''; + generateCsrfToken(fakeForm(null)); + assertEquals(cookieJar, ''); +}); + +Deno.test('generateCsrfToken generates a token and cookie on first use', () => { + cookieJar = ''; + const field = fakeField({}, 'my_field_name'); + + generateCsrfToken(fakeForm(field)); + + assertEquals( + field.getAttribute('data-csrf-protection-cookie-value'), + 'my_field_name', + ); + assertMatch(field.defaultValue, /^[-_/+a-zA-Z0-9]{24,}$/); + assertMatch(cookieJar, /my_field_name_[-_/+a-zA-Z0-9]{24,}=my_field_name/); +}); + +Deno.test('generateCsrfToken keeps an existing valid token/cookie pair unchanged', () => { + cookieJar = ''; + const token = 'a'.repeat(24); + const field = fakeField({ + 'data-csrf-protection-cookie-value': 'existing_name', + }, token); + + generateCsrfToken(fakeForm(field)); + + assertEquals(field.value, token); + assertMatch(cookieJar, new RegExp(`existing_name_${token}=existing_name`)); +}); + +Deno.test('generateCsrfHeaders returns empty headers without a csrf field', () => { + assertEquals(generateCsrfHeaders(fakeForm(null)), {}); +}); + +Deno.test('generateCsrfHeaders returns the cookie-name/token header when both are valid', () => { + const token = 'b'.repeat(24); + const field = fakeField({ + 'data-csrf-protection-cookie-value': 'field_name', + }, token); + + assertEquals(generateCsrfHeaders(fakeForm(field)), { field_name: token }); +}); + +Deno.test('generateCsrfHeaders omits the header when the token is too short', () => { + const field = fakeField({ + 'data-csrf-protection-cookie-value': 'field_name', + }, 'too-short'); + + assertEquals(generateCsrfHeaders(fakeForm(field)), {}); +}); + +Deno.test('removeCsrfToken expires the cookie for a valid token/cookie pair', () => { + cookieJar = ''; + const token = 'c'.repeat(24); + const field = fakeField({ + 'data-csrf-protection-cookie-value': 'field_name', + }, token); + + removeCsrfToken(fakeForm(field)); + + assertMatch(cookieJar, new RegExp(`field_name_${token}=0`)); +}); + +Deno.test('removeCsrfToken does nothing without a csrf field', () => { + cookieJar = ''; + removeCsrfToken(fakeForm(null)); + assertEquals(cookieJar, ''); +}); diff --git a/assets/controllers/elimination_controller.js b/assets/controllers/elimination_controller.js deleted file mode 100644 index 9965206..0000000 --- a/assets/controllers/elimination_controller.js +++ /dev/null @@ -1,14 +0,0 @@ -import {Controller} from '@hotwired/stimulus'; - -export default class extends Controller { - next() { - const currentUrl = new URL(window.location.href); - const pathParts = currentUrl.pathname.split('/'); - // Remove the last segment - pathParts.pop(); - // Update the pathname - currentUrl.pathname = pathParts.join('/'); - // Navigate - window.location.href = currentUrl.href; - } -} diff --git a/assets/controllers/elimination_controller.ts b/assets/controllers/elimination_controller.ts new file mode 100644 index 0000000..eb01445 --- /dev/null +++ b/assets/controllers/elimination_controller.ts @@ -0,0 +1,14 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + next(): void { + const currentUrl = new URL(window.location.href); + const pathParts = currentUrl.pathname.split('/'); + // Remove the last segment + pathParts.pop(); + // Update the pathname + currentUrl.pathname = pathParts.join('/'); + // Navigate + window.location.href = currentUrl.href; + } +} diff --git a/assets/controllers/fullscreen_controller.js b/assets/controllers/fullscreen_controller.js deleted file mode 100644 index fdf479b..0000000 --- a/assets/controllers/fullscreen_controller.js +++ /dev/null @@ -1,40 +0,0 @@ -import {Controller} from '@hotwired/stimulus'; - -const STORAGE_KEY = 'tvdt-fullscreen'; - -export default class extends Controller { - connect() { - this.onFullscreenChange = this.onFullscreenChange.bind(this); - document.addEventListener('fullscreenchange', this.onFullscreenChange); - this.syncState(); - - if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) { - this.request(); - } - } - - disconnect() { - document.removeEventListener('fullscreenchange', this.onFullscreenChange); - } - - toggle() { - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - this.request(); - } - } - - request() { - document.documentElement.requestFullscreen().catch(() => {}); - } - - onFullscreenChange() { - sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0'); - this.syncState(); - } - - syncState() { - document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement)); - } -} diff --git a/assets/controllers/fullscreen_controller.ts b/assets/controllers/fullscreen_controller.ts new file mode 100644 index 0000000..ce6ce36 --- /dev/null +++ b/assets/controllers/fullscreen_controller.ts @@ -0,0 +1,51 @@ +import { Controller } from '@hotwired/stimulus'; + +const STORAGE_KEY = 'tvdt-fullscreen'; + +export default class extends Controller { + connect(): void { + document.addEventListener('fullscreenchange', this.onFullscreenChange); + this.syncState(); + + if ( + sessionStorage.getItem(STORAGE_KEY) === '1' && + !document.fullscreenElement + ) { + this.request(); + } + } + + disconnect(): void { + document.removeEventListener( + 'fullscreenchange', + this.onFullscreenChange, + ); + } + + toggle(): void { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + this.request(); + } + } + + request(): void { + document.documentElement.requestFullscreen().catch(() => {}); + } + + onFullscreenChange = (): void => { + sessionStorage.setItem( + STORAGE_KEY, + document.fullscreenElement ? '1' : '0', + ); + this.syncState(); + }; + + syncState(): void { + document.documentElement.classList.toggle( + 'is-fullscreen', + Boolean(document.fullscreenElement), + ); + } +} diff --git a/assets/controllers/fullscreen_controller_test.ts b/assets/controllers/fullscreen_controller_test.ts new file mode 100644 index 0000000..e561b78 --- /dev/null +++ b/assets/controllers/fullscreen_controller_test.ts @@ -0,0 +1,75 @@ +import { assertEquals } from '@std/assert'; + +// Stimulus's Controller base class constructor only does `this.context = context` +// (see @hotwired/stimulus dist/stimulus.js), and none of this controller's methods +// touch Stimulus-specific getters (element/scope/targets), so a plain `{}` context +// is enough to construct a real instance. `document`/`sessionStorage` are stubbed +// since Deno has no browser DOM. +class FakeClassList { + classes = new Set(); + toggle(name: string, force?: boolean) { + const shouldAdd = force ?? !this.classes.has(name); + if (shouldAdd) this.classes.add(name); + else this.classes.delete(name); + } + contains(name: string) { + return this.classes.has(name); + } +} + +const classList = new FakeClassList(); +let fullscreenElement: unknown = null; +const sessionStore = new Map(); + +// deno-lint-ignore no-explicit-any +(globalThis as any).document = { + documentElement: { classList }, + get fullscreenElement() { + return fullscreenElement; + }, + addEventListener: () => {}, + removeEventListener: () => {}, +}; +// Deno defines a native `sessionStorage` accessor on globalThis (get/set pair), so a +// plain assignment would just call through to it instead of replacing it — redefine +// the property outright. +Object.defineProperty(globalThis, 'sessionStorage', { + value: { + getItem: (key: string) => sessionStore.get(key) ?? null, + setItem: (key: string, value: string) => sessionStore.set(key, value), + }, + configurable: true, +}); + +const { default: FullscreenController } = await import( + './fullscreen_controller.ts' +); + +// deno-lint-ignore no-explicit-any +function makeController(): any { + return new FullscreenController({} as never); +} + +Deno.test('syncState adds is-fullscreen when a fullscreen element is set', () => { + fullscreenElement = { tagName: 'HTML' }; + makeController().syncState(); + assertEquals(classList.contains('is-fullscreen'), true); +}); + +Deno.test('syncState removes is-fullscreen when there is no fullscreen element', () => { + fullscreenElement = null; + makeController().syncState(); + assertEquals(classList.contains('is-fullscreen'), false); +}); + +Deno.test('onFullscreenChange persists the fullscreen state and syncs classes', () => { + fullscreenElement = { tagName: 'HTML' }; + makeController().onFullscreenChange(); + assertEquals(sessionStore.get('tvdt-fullscreen'), '1'); + assertEquals(classList.contains('is-fullscreen'), true); + + fullscreenElement = null; + makeController().onFullscreenChange(); + assertEquals(sessionStore.get('tvdt-fullscreen'), '0'); + assertEquals(classList.contains('is-fullscreen'), false); +}); diff --git a/assets/quiz.js b/assets/quiz.ts similarity index 60% rename from assets/quiz.js rename to assets/quiz.ts index fc0b939..85fdec6 100644 --- a/assets/quiz.js +++ b/assets/quiz.ts @@ -1,4 +1,4 @@ import 'bootstrap/dist/css/bootstrap.min.css'; import './styles/quiz.scss'; -import './stimulus.js'; -import './bootstrap.js'; +import './stimulus.ts'; +import './bootstrap.ts'; diff --git a/assets/stimulus.js b/assets/stimulus.ts similarity index 65% rename from assets/stimulus.js rename to assets/stimulus.ts index b22f20c..0bcc49c 100644 --- a/assets/stimulus.js +++ b/assets/stimulus.ts @@ -1,3 +1,3 @@ import { startStimulusApp } from '@symfony/stimulus-bundle'; -const app = startStimulusApp(); +startStimulusApp(); diff --git a/assets/types/global.d.ts b/assets/types/global.d.ts new file mode 100644 index 0000000..2975db4 --- /dev/null +++ b/assets/types/global.d.ts @@ -0,0 +1,11 @@ +// Ambient declarations for the parts of the AssetMapper toolchain that Deno's +// module resolution doesn't otherwise understand: CSS/SCSS side-effect imports +// (handled by AssetMapper at build time, not a JS module) and the Symfony +// stimulus-bundle loader (a local vendor/ file, not an npm package). + +declare module '*.css'; +declare module '*.scss'; + +declare module '@symfony/stimulus-bundle' { + export function startStimulusApp(): unknown; +} diff --git a/composer.json b/composer.json index 322857e..08cf6d0 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "phpdocumentor/reflection-docblock": "^6.0.3", "phpoffice/phpspreadsheet": "^5.5", "phpstan/phpdoc-parser": "^2.3.2", + "sensiolabs/typescript-bundle": "^0.2.2", "sentry/sentry-symfony": "^5.9.0", "stof/doctrine-extensions-bundle": "^1.15.3", "symfony/asset": "8.1.*", diff --git a/composer.lock b/composer.lock index 5cbc0a7..ed4a882 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ccae654dd9c952e8920d9cb9c0f35ff5", + "content-hash": "cca03585c291226d36bbeabf57a08bec", "packages": [ { "name": "composer/pcre", @@ -2751,6 +2751,62 @@ }, "time": "2019-03-08T08:55:37+00:00" }, + { + "name": "sensiolabs/typescript-bundle", + "version": "v0.2.2", + "source": { + "type": "git", + "url": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle.git", + "reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sensiolabs/AssetMapperTypeScriptBundle/zipball/b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce", + "reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/asset-mapper": "^6.3|^7.0|^8.0", + "symfony/console": "^6.3|^7.0|^8.0", + "symfony/http-client": "^6.3|^7.0|^8.0", + "symfony/process": "^6.3|^7.0|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpstan/phpstan-symfony": "^1.3", + "phpunit/phpunit": "^10.5", + "symfony/filesystem": "^6.3|^7.0|^8.0", + "symfony/framework-bundle": "^6.3|^7.0|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Sensiolabs\\TypeScriptBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maelan LE BORGNE", + "homepage": "https://github.com/maelanleborgne" + } + ], + "description": "TypeScript support for Symfony + AssetMapper", + "homepage": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle", + "keywords": [ + "asset-mapper", + "typescript" + ], + "support": { + "issues": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/issues", + "source": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/tree/v0.2.2" + }, + "time": "2025-08-28T10:10:44+00:00" + }, { "name": "sentry/sentry", "version": "4.29.0", diff --git a/config/bundles.php b/config/bundles.php index 717fcf1..5b91d0f 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -6,6 +6,7 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; +use Sensiolabs\TypeScriptBundle\SensiolabsTypeScriptBundle; use Sentry\SentryBundle\SentryBundle; use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; @@ -38,4 +39,5 @@ return [ DAMADoctrineTestBundle::class => ['test' => true], StofDoctrineExtensionsBundle::class => ['all' => true], SymfonyCastsResetPasswordBundle::class => ['all' => true], + SensiolabsTypeScriptBundle::class => ['all' => true], ]; diff --git a/config/packages/asset_mapper.yaml b/config/packages/asset_mapper.yaml index 5fb548c..c68da56 100644 --- a/config/packages/asset_mapper.yaml +++ b/config/packages/asset_mapper.yaml @@ -6,8 +6,12 @@ framework: excluded_patterns: - '*/assets/styles/_*.scss' - '*/assets/styles/**/_*.scss' + - '*/assets/**/*_test.ts' + - '*/assets/types/*.d.ts' missing_import_mode: strict +sensiolabs_typescript: + source_dir: ['%kernel.project_dir%/assets'] when@prod: framework: diff --git a/config/reference.php b/config/reference.php index 2847703..8c23265 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1496,6 +1496,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600 * enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true * } + * @psalm-type SensiolabsTypescriptConfig = array{ + * source_dir?: list, + * binary_download_dir?: scalar|Param|null, // The directory where the SWC binary will be downloaded // Default: "%kernel.project_dir%/var" + * swc_binary?: scalar|Param|null, // The SWC binary to use // Default: null + * swc_config_file?: scalar|Param|null, // Path to .swcrc configuration file to use // Default: "%kernel.project_dir%/.swcrc" + * swc_version?: scalar|Param|null, // The SWC version to use // Default: "v1.3.92" + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1512,6 +1519,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * turbo?: TurboConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, + * sensiolabs_typescript?: SensiolabsTypescriptConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1531,6 +1539,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * turbo?: TurboConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, + * sensiolabs_typescript?: SensiolabsTypescriptConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1549,6 +1558,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * turbo?: TurboConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, + * sensiolabs_typescript?: SensiolabsTypescriptConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1568,6 +1578,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * dama_doctrine_test?: DamaDoctrineTestConfig, * stof_doctrine_extensions?: StofDoctrineExtensionsConfig, * symfonycasts_reset_password?: SymfonycastsResetPasswordConfig, + * sensiolabs_typescript?: SensiolabsTypescriptConfig, * }, * ... */ return [ - 'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true], - 'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true], + 'quiz' => ['path' => './assets/quiz.ts', 'entrypoint' => true], + 'backoffice' => ['path' => './assets/backoffice.ts', 'entrypoint' => true], '@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'], 'bootstrap' => ['version' => '5.3.8'], '@popperjs/core' => ['version' => '2.11.8'], diff --git a/symfony.lock b/symfony.lock index 99aafa4..f402963 100644 --- a/symfony.lock +++ b/symfony.lock @@ -98,6 +98,9 @@ "tests/bootstrap.php" ] }, + "sensiolabs/typescript-bundle": { + "version": "v0.2.2" + }, "sentry/sentry-symfony": { "version": "5.8", "recipe": {