Migrate frontend to TypeScript with Deno-based tooling (#209)

* 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.

* fix: build TypeScript assets before running PHPUnit in CI

The tests job ran bin/console sass:build but never typescript:build,
so var/typescript/ didn't exist and any page rendering the importmap
(e.g. backoffice/base.html.twig) errored during tests.

* test: add regression test for backoffice navbar-toggler dead target

Guards against the navbar-toggler button pointing at a collapse
target (#navbarSupportedContent) that isn't rendered for the current
user — the bug hit on the login page before #210 restructured the
nav to always render at least one item (the Releases link) regardless
of auth state.

* fix: stop hand-enumerating TS files for deno check

deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
was duplicated in CI and the Justfile, and silently misses any new
controller subdirectory (fmt/lint/test already recurse assets/ via
deno.json). Add a top-level exclude for assets/vendor/ (respected by
all deno subcommands, unlike the per-task include/exclude blocks) so
deno check assets/ can recurse safely instead.

* fix: GitHubReleasesService date parsing and falsy release name

- Move the release-mapping array_map inside the try block so a
  malformed published_at (or any other parse failure) degrades to the
  same empty-list fallback as an HTTP failure, instead of throwing
  uncaught out of the cache callback.
- Stop treating a release literally named "0" as unnamed — the old
  `?:` fallback is falsy for that string and silently substituted the
  tag name instead.
- Render release dates in UTC explicitly; Twig's date filter otherwise
  silently converts to the app's default timezone (Europe/Amsterdam),
  which could show the wrong calendar day for releases near midnight.

* docs: add scope-creep-as-a-service rule to CLAUDE.md

Per user instruction: when a review turns up a real bug outside the
current task's scope, fix it in the same MR (with a regression test)
rather than just reporting it, unless it needs a human design call.
This commit is contained in:
2026-07-12 22:11:00 +02:00
committed by GitHub
parent 4e98909f11
commit b4a27a7c0d
44 changed files with 1473 additions and 509 deletions
@@ -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<string, string> = {}, 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<typeof fakeField> | 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, '');
});