From 5859de576706e9c2b56ee88cfa3666a8171577df Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:07:17 +0000
Subject: [PATCH 01/29] chore: set up monorepo with workspaces, linting and CI
---
.claude/agents/backend-utvikler.md | 13 +
.claude/agents/frontend-utvikler.md | 15 +
.claude/agents/reviewer.md | 11 +
.claude/agents/tester.md | 12 +
.editorconfig | 9 +
.env.example | 17 +
.gitignore | 17 +
.gitlab-ci.yml | 41 +
.husky/pre-commit | 1 +
.prettierignore | 7 +
.prettierrc.json | 6 +
BLOCKERS.md | 8 +
CLAUDE.md | 47 +
PLAN.md | 59 +
backend/package.json | 30 +
backend/src/index.ts | 2 +
backend/tsconfig.build.json | 6 +
backend/tsconfig.json | 11 +
backend/vitest.config.ts | 11 +
data/.gitkeep | 0
docs/beslutninger.md | 17 +
docs/ki-logg.md | 5 +
eslint.config.js | 50 +
frontend/index.html | 14 +
frontend/package.json | 34 +
frontend/src/main.tsx | 10 +
frontend/src/test/setup.ts | 7 +
frontend/tsconfig.json | 10 +
frontend/vite.config.ts | 24 +
package-lock.json | 7442 +++++++++++++++++++++++++++
package.json | 48 +
tsconfig.base.json | 16 +
32 files changed, 8000 insertions(+)
create mode 100644 .claude/agents/backend-utvikler.md
create mode 100644 .claude/agents/frontend-utvikler.md
create mode 100644 .claude/agents/reviewer.md
create mode 100644 .claude/agents/tester.md
create mode 100644 .editorconfig
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 .gitlab-ci.yml
create mode 100644 .husky/pre-commit
create mode 100644 .prettierignore
create mode 100644 .prettierrc.json
create mode 100644 BLOCKERS.md
create mode 100644 CLAUDE.md
create mode 100644 PLAN.md
create mode 100644 backend/package.json
create mode 100644 backend/src/index.ts
create mode 100644 backend/tsconfig.build.json
create mode 100644 backend/tsconfig.json
create mode 100644 backend/vitest.config.ts
create mode 100644 data/.gitkeep
create mode 100644 docs/beslutninger.md
create mode 100644 docs/ki-logg.md
create mode 100644 eslint.config.js
create mode 100644 frontend/index.html
create mode 100644 frontend/package.json
create mode 100644 frontend/src/main.tsx
create mode 100644 frontend/src/test/setup.ts
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/vite.config.ts
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 tsconfig.base.json
diff --git a/.claude/agents/backend-utvikler.md b/.claude/agents/backend-utvikler.md
new file mode 100644
index 0000000..d2a7d98
--- /dev/null
+++ b/.claude/agents/backend-utvikler.md
@@ -0,0 +1,13 @@
+---
+name: backend-utvikler
+description: Implementerer database, importskript og GraphQL-API (Node + TypeScript + PostgreSQL). Bruk for alt under backend/.
+model: sonnet
+---
+
+Du er backend-utvikler i IT2810-prosjektet. Følg CLAUDE.md.
+
+- Jobb kun i `backend/` og delte konfigfiler du blir bedt om.
+- All søk/filter/sortering i SQL, parameteriserte spørringer, cursor-paginering.
+- Skriv eller oppdater tester for det du endrer (Vitest mot testdatabase).
+- Kjør `npm run lint -w backend && npm run typecheck -w backend && npm test -w backend` før du svarer.
+- Svar lederen kort: hva endret, hvilke filer, testresultat, evt. åpne problemer. Ikke commit – lederen gjør det.
diff --git a/.claude/agents/frontend-utvikler.md b/.claude/agents/frontend-utvikler.md
new file mode 100644
index 0000000..97fcc75
--- /dev/null
+++ b/.claude/agents/frontend-utvikler.md
@@ -0,0 +1,15 @@
+---
+name: frontend-utvikler
+description: Implementerer React/TypeScript/Vite-klienten med Apollo Client. Bruk for alt under frontend/.
+model: sonnet
+---
+
+Du er frontend-utvikler i IT2810-prosjektet. Følg CLAUDE.md.
+
+- Jobb kun i `frontend/`.
+- Semantisk HTML, tastaturstøtte, synlig fokus, labels på alle input, `aria-live` der innhold endres.
+- Ingen sortering/filtrering på klienten – alt via GraphQL-variabler.
+- Hold bundlen liten; spør deg selv om hvert nytt bibliotek er nødvendig.
+- Skriv komponenttester (Vitest + Testing Library) for det du lager.
+- Kjør `npm run lint -w frontend && npm run typecheck -w frontend && npm test -w frontend` før du svarer.
+- Svar lederen kort: hva endret, hvilke filer, testresultat, evt. åpne problemer. Ikke commit.
diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md
new file mode 100644
index 0000000..d60b4b7
--- /dev/null
+++ b/.claude/agents/reviewer.md
@@ -0,0 +1,11 @@
+---
+name: reviewer
+description: Går gjennom en endring (diff) for korrekthet, sikkerhet, tilgjengelighet og etterlevelse av CLAUDE.md.
+model: sonnet
+---
+
+Du er reviewer i IT2810-prosjektet. Følg CLAUDE.md.
+
+- Les diffen lederen peker på. Se etter feil, SQL-injeksjon, manglende validering, klient-side sortering/filtrering, a11y-brudd og utestede kanttilfeller.
+- Rangér funn: blokkerende / bør fikses / valgfritt. Oppgi fil og linje.
+- Ikke endre kode selv. Svar kort. Ikke commit.
diff --git a/.claude/agents/tester.md b/.claude/agents/tester.md
new file mode 100644
index 0000000..f753f44
--- /dev/null
+++ b/.claude/agents/tester.md
@@ -0,0 +1,12 @@
+---
+name: tester
+description: Kjører og utvider tester (Vitest, Testing Library, Playwright + axe). Bruk etter hver endring for å verifisere.
+model: sonnet
+---
+
+Du er tester i IT2810-prosjektet. Følg CLAUDE.md.
+
+- Kjør relevante tester for endringen og rapporter nøyaktig hva som feilet (kommando + utdrag av output).
+- Legg til tester for utypisk bruk: tomt søk, ingen treff, spesialtegn, veldig lange strenger, nettverksfeil.
+- Ikke endre produksjonskode; foreslå heller fiks til lederen.
+- Svar lederen kort: hvilke tester, resultat, evt. funn. Ikke commit.
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..86a63dc
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,9 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..eb463fb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,17 @@
+# Kopier til .env og tilpass. .env skal aldri committes.
+
+# Backend
+PORT=3001
+DATABASE_URL=postgres://postgres@localhost:5432/project2
+# Egen database for tester – tømmes av testene.
+TEST_DATABASE_URL=postgres://postgres@localhost:5432/project2_test
+# Tillatt opprinnelse for CORS i utvikling (Vite dev-server).
+CORS_ORIGIN=http://localhost:5173
+
+# Import: mappe med title.basics.tsv.gz og title.ratings.tsv.gz
+IMDB_DATA_DIR=./data
+# Minste antall stemmer for at en tittel tas med
+IMDB_MIN_VOTES=100
+
+# Frontend (valgfri – standard er /graphql)
+VITE_GRAPHQL_URL=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a1818f7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+node_modules/
+dist/
+coverage/
+*.log
+.env
+.env.*
+!.env.example
+data/*.tsv
+data/*.tsv.gz
+!data/.gitkeep
+playwright-report/
+test-results/
+blob-report/
+.vite/
+*.tsbuildinfo
+.DS_Store
+.claude/DONE.local
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..3d15688
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,41 @@
+# CI for IT2810 prosjekt 2: lint, typecheck og enhetstester på hver push/MR.
+default:
+ image: node:22
+ cache:
+ key:
+ files:
+ - package-lock.json
+ paths:
+ - .npm/
+ before_script:
+ - npm ci --cache .npm --prefer-offline
+
+stages:
+ - check
+ - test
+
+variables:
+ HUSKY: '0'
+
+lint:
+ stage: check
+ script:
+ - npm run lint
+
+typecheck:
+ stage: check
+ script:
+ - npm run typecheck
+
+unit-tests:
+ stage: test
+ services:
+ - name: postgres:16
+ alias: postgres
+ variables:
+ POSTGRES_DB: project2_test
+ POSTGRES_USER: postgres
+ POSTGRES_HOST_AUTH_METHOD: trust
+ TEST_DATABASE_URL: postgres://postgres@postgres:5432/project2_test
+ script:
+ - npm test
diff --git a/.husky/pre-commit b/.husky/pre-commit
new file mode 100644
index 0000000..2312dc5
--- /dev/null
+++ b/.husky/pre-commit
@@ -0,0 +1 @@
+npx lint-staged
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..f4e4841
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+coverage
+playwright-report
+test-results
+package-lock.json
+data
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..9014aaa
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "singleQuote": true,
+ "semi": true,
+ "trailingComma": "all",
+ "printWidth": 100
+}
diff --git a/BLOCKERS.md b/BLOCKERS.md
new file mode 100644
index 0000000..169d645
--- /dev/null
+++ b/BLOCKERS.md
@@ -0,0 +1,8 @@
+# Blokkeringer som krever Nicolay
+
+Punkter her kan ikke løses av agentene alene. Hvert punkt sier hva som trengs.
+
+- **IMDb-datasettet kunne ikke lastes ned i utviklingsmiljøet.** Sky-containeren agentene kjørte i har
+ en nettverkspolicy som blokkerer `datasets.imdbws.com` (HTTP 403 fra proxyen). Importskriptet er
+ skrevet for de ekte filene og testet mot et syntetisk datasett i samme format
+ (`npm run db:fixture -w backend`). Last ned de ekte filene selv og kjør importen, se `docs/oppsett.md`.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5919673
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,47 @@
+# IT2810 Prosjekt 2 – regler for Claude Code
+
+Du jobber autonomt. Nicolay ser ikke på skjermen før prosjektet er ferdig. Ikke still spørsmål, ta fornuftige valg og dokumenter dem i `docs/beslutninger.md`.
+
+## Roller
+
+- **Hovedsesjonen (Opus) er daglig leder.** Den planlegger, deler opp arbeid, delegerer til subagenter, verifiserer og krysser av i `PLAN.md`. Lederen skriver helst ikke produksjonskode selv.
+- **Subagenter (Sonnet):** `backend-utvikler`, `frontend-utvikler`, `tester`, `reviewer`. Gi dem små, avgrensede oppgaver med tydelig ferdigkriterium. Kjør uavhengige oppgaver parallelt.
+
+## Arbeidsløkke (hver runde)
+
+1. Les `PLAN.md` og `BLOCKERS.md`.
+2. Velg neste uavkryssede punkt (eller flere uavhengige).
+3. Deleger → la `reviewer` gå gjennom endringen → la `tester` kjøre relevante tester.
+4. Kjør selv `npm run lint && npm run typecheck && npm test` i berørte pakker. Rødt = ikke ferdig.
+5. Commit, kryss av i `PLAN.md`, skriv én linje i `docs/ki-logg.md`.
+6. Er alle MVP-kriterier oppfylt og verifisert: opprett filen `.claude/DONE`.
+
+## Git
+
+- Jobb alltid på grener fra `mvp`. Aldri commit eller push til `main`, aldri force-push.
+- Én gren per oppgave (`feat/…`, `fix/…`, `test/…`), merge til `mvp` når review og tester er grønne.
+- Små commits med Conventional Commits-meldinger på engelsk.
+- Hvis `glab` er innlogget: opprett issue per PLAN-punkt og MR mot `mvp`, reviewer kommenterer i MR-en. Ellers: gjør det samme lokalt og loggfør i `docs/ki-logg.md`.
+
+## Tekniske rammer (ikke avvik uten å skrive begrunnelse i beslutninger.md)
+
+- Monorepo: `frontend/` (React + TS + Vite), `backend/` (Node + TS + GraphQL), npm workspaces.
+- State: Apollo Client (cache + reactive vars). URL speiler søk/filter/sortering.
+- Database: PostgreSQL, ingen Docker. `pg_trgm` + GIN-indekser. Migrasjoner og importskript i repo.
+- Søk, filter og sortering skjer ALLTID i SQL på hele datasettet. Cursor-basert paginering.
+- Søk debounces (300 ms). Ingen requests for tomme eller uendrede søk.
+- Backend på port 3001. Frontend bygges med `base: '/project2/'`.
+- Tilgjengelighet: WCAG 2.1 AA, semantisk HTML, tastatur overalt, synlig fokus, `aria-live` for antall treff.
+- Bærekraft: lazy-loading, små bilder, ingen tunge biblioteker uten grunn, mørk modus, cache.
+- Kommentarer skrives for eksterne lesere: forklar _hvorfor_, ikke _hva_.
+
+## Tester
+
+- Vitest + Testing Library for komponenter, Vitest for GraphQL-resolvere mot testdatabase, Playwright for E2E.
+- Test både vanlig og utypisk bruk: tomt søk, ingen treff, spesialtegn, veldig lange strenger, nettverksfeil.
+
+## Grenser
+
+- Ingen `sudo`, ingen endringer utenfor repoet, ingen hemmeligheter i git (`.env` i `.gitignore`, `.env.example` i repo).
+- README.md skrives av gruppa. Agentene legger bare fakta og installasjonssteg i `docs/`, som gruppa kan bygge README på.
+- Står du fast på noe som krever Nicolay (passord, VM, VPN, tilganger): skriv det i `BLOCKERS.md` og gå videre.
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..cdd0e16
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,59 @@
+# PLAN – MVP
+
+Målet er nådd når alle bokser under er krysset av og verifisert. Lederen krysser av, ikke utviklerne.
+
+## Datasett (standardvalg)
+
+IMDb non-commercial datasets (`title.basics.tsv.gz`, `title.ratings.tsv.gz`) fra https://datasets.imdbws.com – ingen API-nøkkel. Filtrer til filmer og serier med minst 100 stemmer (~100k+ rader). Bilder hoppes over i MVP (bærekraft), evt. placeholders.
+Brukergenererte data: anmeldelser (1–5 stjerner + tekst) og "min liste".
+
+## M0 – Oppsett
+
+- [x] Monorepo med npm workspaces, TypeScript strict, ESLint, Prettier, Husky + lint-staged
+- [ ] Scripts i rot: `dev`, `build`, `lint`, `typecheck`, `test`, `test:e2e`, `db:migrate`, `db:seed`
+- [x] `.env.example`, `.gitignore`, `docs/beslutninger.md`, `docs/ki-logg.md`, `BLOCKERS.md`
+- [x] GitLab CI (`.gitlab-ci.yml`): lint, typecheck, unit-tester
+
+## M1 – Database og import
+
+- [ ] Skjema + migrasjoner (titles, genres, reviews, lists), indekser inkl. `pg_trgm` GIN på tittel
+- [ ] Importskript som strømmer TSV (ikke les alt i minnet) og kan kjøres på nytt (idempotent)
+- [ ] `EXPLAIN ANALYZE` for søk før/etter indeks lagret i `docs/ytelse.md`
+
+## M2 – GraphQL-API
+
+- [ ] `search(query, filters, sort, first, after)` med connection-type (edges, pageInfo, totalCount)
+- [ ] `title(id)` med detaljer og anmeldelser
+- [ ] `facets(query, filters)` → antall treff per sjanger / tiår / type
+- [ ] Mutations: `addReview`, `toggleList` (anonym bruker-id via cookie/localStorage-uuid)
+- [ ] Input-validering, feilhåndtering, maks `first`, query-dybde begrenset
+- [ ] API-tester for alle queries/mutations inkl. kanttilfeller
+
+## M3 – Frontend
+
+- [ ] Søkefelt med debounce, case-insensitivt, tøm-knapp
+- [ ] Resultatliste med uendelig scroll + "Last flere"-knapp, skeleton-lasting
+- [ ] Filterpanel (sjanger, tiår, type, min. rating) med antall treff, aktive filtre som chips, nullstill
+- [ ] Sortering (relevans, rating, år, tittel) – alltid på server
+- [ ] Detaljside med anmeldelser og skjema for ny anmeldelse
+- [ ] "Min liste"-side
+- [ ] State i URL, tilbake-knapp bevarer scroll
+- [ ] Tomt/ingen treff/feil-tilstander med "prøv igjen"
+- [ ] Mørk modus (følger system), responsivt ned til 320 px
+
+## M4 – Kvalitet
+
+- [ ] Komponenttester for søk, liste, filter, detaljside, anmeldelsesskjema
+- [ ] Playwright E2E: søk → filtrer → sorter → scroll → detalj → skriv anmeldelse → se den i lista
+- [ ] axe-sjekk i Playwright uten feil på alle sider
+- [ ] Lighthouse ≥ 90 på alle kategorier (resultat i `docs/ytelse.md`)
+- [ ] Alle tester, lint og typecheck grønne fra ren klon (`npm ci`)
+
+## M5 – Deploy-klargjøring (kjøres ikke mot VM uten Nicolay)
+
+- [ ] `docs/deploy.md`: Postgres-oppsett, systemd-fil for backend, Apache-konfig for `/project2`, gzip/cache-headere
+- [ ] `deploy/`-mappe med systemd-unit og Apache-konfig ferdig utfylt for `it2810-17.idi.ntnu.no`
+
+## Ferdig
+
+- [ ] Oppsummering i `docs/status.md`: hva er gjort, hva gjenstår, hva Nicolay må gjøre
diff --git a/backend/package.json b/backend/package.json
new file mode 100644
index 0000000..d930e86
--- /dev/null
+++ b/backend/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "backend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "tsx watch src/index.ts",
+ "build": "tsc -p tsconfig.build.json",
+ "start": "node dist/src/index.js",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "db:migrate": "tsx scripts/migrate.ts",
+ "db:seed": "tsx scripts/import-imdb.ts",
+ "db:fixture": "tsx scripts/generate-fixture.ts"
+ },
+ "devDependencies": {
+ "@types/node": "^26.6.3",
+ "@types/pg": "^8.23.1",
+ "tsx": "^4.23.15",
+ "typescript": "~6.0",
+ "vitest": "^5.0.2"
+ },
+ "dependencies": {
+ "dotenv": "^18.0.4",
+ "graphql": "^17.0.2",
+ "graphql-yoga": "^5.24.1",
+ "pg": "^8.23.0"
+ }
+}
diff --git a/backend/src/index.ts b/backend/src/index.ts
new file mode 100644
index 0000000..af93550
--- /dev/null
+++ b/backend/src/index.ts
@@ -0,0 +1,2 @@
+// Midlertidig inngangspunkt – erstattes av GraphQL-serveren i M2.
+export const PORT = Number(process.env.PORT ?? 3001);
diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json
new file mode 100644
index 0000000..42f867c
--- /dev/null
+++ b/backend/tsconfig.build.json
@@ -0,0 +1,6 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": { "noEmit": false, "sourceMap": true },
+ "include": ["src", "scripts"],
+ "exclude": ["**/*.test.ts"]
+}
diff --git a/backend/tsconfig.json b/backend/tsconfig.json
new file mode 100644
index 0000000..3f1c36a
--- /dev/null
+++ b/backend/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "types": ["node"],
+ "outDir": "dist",
+ "rootDir": "."
+ },
+ "include": ["src", "scripts", "test", "vitest.config.ts"]
+}
diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts
new file mode 100644
index 0000000..e14cc59
--- /dev/null
+++ b/backend/vitest.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'scripts/**/*.test.ts'],
+ // Testene deler én testdatabase; parallelle filer ville tråkket på hverandre.
+ fileParallelism: false,
+ passWithNoTests: true,
+ },
+});
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/beslutninger.md b/docs/beslutninger.md
new file mode 100644
index 0000000..21a3770
--- /dev/null
+++ b/docs/beslutninger.md
@@ -0,0 +1,17 @@
+# Beslutninger
+
+Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst.
+
+- **TypeScript 6.0 i stedet for 7.** `typescript-eslint` støtter bare `typescript <6.1`. Vi valgte
+ lint-støtte framfor nyeste kompilator.
+- **ESLint 9 i stedet for 10.** `eslint-plugin-jsx-a11y` støtter ikke ESLint 10 ennå, og
+ tilgjengelighetslinting er et krav.
+- **Git-grener.** CLAUDE.md sier at arbeidet skal skje på grener fra `mvp`. Sky-sesjonen kan bare
+ pushe til én gren (`claude/webutvikling-projekt-2-k5lq6q`), så den grenen spiller rollen til
+ `mvp`. Hver oppgave lages på en lokal `feat/…`-gren og merges inn med `--no-ff`, slik at historikken
+ viser grenene. Ingenting pushes til `main`.
+- **Ingen GitLab-issues/MR-er.** `glab` er ikke tilgjengelig i miljøet, så issue- og review-flyten
+ loggføres i `docs/ki-logg.md` i stedet, slik CLAUDE.md beskriver.
+- **Syntetisk testdatasett.** IMDb-nedlasting er blokkert i miljøet (se `BLOCKERS.md`). Et skript
+ genererer filer i nøyaktig samme TSV-format, så importkoden er den samme for ekte og syntetiske
+ data.
diff --git a/docs/ki-logg.md b/docs/ki-logg.md
new file mode 100644
index 0000000..15d65bb
--- /dev/null
+++ b/docs/ki-logg.md
@@ -0,0 +1,5 @@
+# KI-logg
+
+Én linje per fullført oppgave: dato – hva – hvem (agent) – verifisering.
+
+- 2026-09-30 – M0 oppsett: monorepo, TS strict, ESLint/Prettier, Husky + lint-staged, GitLab CI – leder – lint, typecheck, test og build grønne.
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..ef27e03
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,50 @@
+import js from '@eslint/js';
+import tseslint from 'typescript-eslint';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import jsxA11y from 'eslint-plugin-jsx-a11y';
+import prettier from 'eslint-config-prettier';
+import globals from 'globals';
+
+export default tseslint.config(
+ {
+ ignores: [
+ '**/node_modules/**',
+ '**/dist/**',
+ '**/coverage/**',
+ 'playwright-report/**',
+ 'test-results/**',
+ 'data/**',
+ ],
+ },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ['**/*.{ts,tsx,js}'],
+ rules: {
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ { argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
+ ],
+ },
+ },
+ {
+ files: ['backend/**/*.ts', 'e2e/**/*.ts', '*.{js,ts}'],
+ languageOptions: { globals: globals.node },
+ },
+ {
+ files: ['frontend/**/*.{ts,tsx}'],
+ languageOptions: { globals: globals.browser },
+ plugins: {
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ 'jsx-a11y': jsxA11y,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ ...jsxA11y.flatConfigs.recommended.rules,
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
+ },
+ },
+ prettier,
+);
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..8d1bd48
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Filmsøk – IT2810 gruppe 17
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..9a787be
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "frontend",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@apollo/client": "^4.3.1",
+ "graphql": "^17.0.2",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router": "^8.4.0"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "jsdom": "^30.1.1",
+ "typescript": "~6.0",
+ "vite": "^8.3.1",
+ "vitest": "^5.0.2"
+ }
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..f0bf301
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ Filmsøk
+
+ ,
+);
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 0000000..0d74b73
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1,7 @@
+import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+import { afterEach } from 'vitest';
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..8c686bb
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "jsx": "react-jsx",
+ "types": ["vite/client", "@testing-library/jest-dom"],
+ "noEmit": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..3d54521
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,24 @@
+///
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ // Appen serveres fra http://it2810-17.idi.ntnu.no/project2/ på VM-en.
+ base: '/project2/',
+ plugins: [react()],
+ server: {
+ proxy: {
+ // Samme URL-struktur i utvikling som i produksjon (Apache proxyer /project2/graphql).
+ '/project2/graphql': {
+ target: 'http://localhost:3001',
+ rewrite: (path) => path.replace(/^\/project2/, ''),
+ },
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ include: ['src/**/*.test.{ts,tsx}'],
+ passWithNoTests: true,
+ },
+});
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..4fa5f6f
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,7442 @@
+{
+ "name": "it2810-project2",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "it2810-project2",
+ "workspaces": [
+ "backend",
+ "frontend"
+ ],
+ "devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
+ "@eslint/js": "^9.39.5",
+ "@playwright/test": "^1.63.0",
+ "concurrently": "^10.0.5",
+ "eslint": "^9.39.5",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.7",
+ "globals": "^17.12.0",
+ "husky": "^9.1.7",
+ "lint-staged": "^17.6.0",
+ "prettier": "^3.9.9",
+ "typescript": "~6.0",
+ "typescript-eslint": "^8.71.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "backend": {
+ "version": "0.1.0",
+ "dependencies": {
+ "dotenv": "^18.0.4",
+ "graphql": "^17.0.2",
+ "graphql-yoga": "^5.24.1",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@types/node": "^26.6.3",
+ "@types/pg": "^8.23.1",
+ "tsx": "^4.23.15",
+ "typescript": "~6.0",
+ "vitest": "^5.0.2"
+ }
+ },
+ "frontend": {
+ "version": "0.1.0",
+ "dependencies": {
+ "@apollo/client": "^4.3.1",
+ "graphql": "^17.0.2",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router": "^8.4.0"
+ },
+ "devDependencies": {
+ "@testing-library/dom": "^10.4.2",
+ "@testing-library/jest-dom": "^7.0.1",
+ "@testing-library/react": "^16.3.3",
+ "@testing-library/user-event": "^14.6.7",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.1",
+ "jsdom": "^30.1.1",
+ "typescript": "~6.0",
+ "vite": "^8.3.1",
+ "vitest": "^5.0.2"
+ }
+ },
+ "frontend/node_modules/@apollo/client": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.1.tgz",
+ "integrity": "sha512-MFHzUfHxS+A6AzH+cS4896jHuSTRw5ACUANijz1gyesWZDLsbgZ17oCb/KfRAOUuoUr8AzCaBE+xJPi7R7ZOCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.1.1",
+ "@wry/caches": "^1.0.0",
+ "@wry/equality": "^0.5.6",
+ "@wry/trie": "^0.5.0",
+ "graphql-tag": "^2.12.6",
+ "optimism": "^0.18.0",
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "graphql": "^16.0.0 || ^17.0.0",
+ "graphql-ws": "^5.5.5 || ^6.0.3",
+ "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
+ "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc",
+ "rxjs": "^7.3.0",
+ "subscriptions-transport-ws": "^0.9.0 || ^0.11.0"
+ },
+ "peerDependenciesMeta": {
+ "graphql-ws": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "subscriptions-transport-ws": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz",
+ "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.4.1",
+ "@csstools/css-color-parser": "^4.2.4",
+ "@csstools/css-parser-algorithms": "^4.0.1",
+ "@csstools/css-tokenizer": "^4.0.2",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.2.tgz",
+ "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.1.0",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@axe-core/playwright": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
+ "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "axe-core": "~4.13.0"
+ },
+ "peerDependencies": {
+ "playwright-core": ">= 1.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.9",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
+ "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
+ "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz",
+ "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.1",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz",
+ "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.2",
+ "@csstools/css-calc": "^3.4.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.1",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz",
+ "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz",
+ "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
+ "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@envelop/core": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/@envelop/core/-/core-5.6.1.tgz",
+ "integrity": "sha512-KEbJ7yrAA+W7IY9MZzpZJ77w/72ndaV/16+hhPapd+0Ohjqffl0mmF3QWy62/m3nRP4qidD+1eiEjJhS/O38Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "@envelop/instrumentation": "^1.0.1",
+ "@envelop/types": "^5.2.2",
+ "@whatwg-node/promise-helpers": "^1.2.4",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@envelop/instrumentation": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.1.tgz",
+ "integrity": "sha512-FuexE2qd3Yc1Kh1DjTliwGxb9kyyv4ySBNnfJOxnQasVmGdcqX6AkkO5sIMxLWFRftp8PErOcIn2dQW/1RwytQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@whatwg-node/promise-helpers": "^1.2.1",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@envelop/types": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@envelop/types/-/types-5.2.2.tgz",
+ "integrity": "sha512-nl9cmUh8F+2/NNzpcLjzNqtaVn0m8XSzzxrPU2ByHhlz4Hqlc5QBfRsemZkoJMKrMviJXyJYdKZiUP1o70F3Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "@whatwg-node/promise-helpers": "^1.0.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz",
+ "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.3.2",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
+ "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
+ "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@fastify/busboy": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.2.tgz",
+ "integrity": "sha512-yXSS27qPExaXeuLvMRMXOLtpipzfQYNjG3FkunDWKGfMYjKuhFXko9CVzqxm8jcF+lmtS9Fd89QNdh9XDjnbNg==",
+ "license": "MIT"
+ },
+ "node_modules/@graphql-tools/executor": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/executor/-/executor-2.0.1.tgz",
+ "integrity": "sha512-rvlOaoTUXUUHlu7090e94566SeBnU9PV6xMztIt2s8StXy5HbP0p+tYSfg3N/gCdEzJ78ZQLjXJz4UFgWGZ/7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-tools/utils": "^12.0.1",
+ "@graphql-typed-document-node/core": "^3.2.0",
+ "@repeaterjs/repeater": "^3.1.0",
+ "@whatwg-node/disposablestack": "^0.0.6",
+ "@whatwg-node/promise-helpers": "^1.0.0",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/executor/node_modules/@graphql-tools/utils": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.1.tgz",
+ "integrity": "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.1.1",
+ "@whatwg-node/promise-helpers": "^1.0.0",
+ "cross-inspect": "1.0.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/merge": {
+ "version": "9.2.4",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.4.tgz",
+ "integrity": "sha512-vV+8uWNWn0+OsqT0r22lZuoT0cTe6fBqBtpLHre2rriLjI/ZTrsOHmabLPTUOyzgFnRrS2PzFSYGL3zKL1Wj4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-tools/utils": "^12.0.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/merge/node_modules/@graphql-tools/utils": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.1.tgz",
+ "integrity": "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.1.1",
+ "@whatwg-node/promise-helpers": "^1.0.0",
+ "cross-inspect": "1.0.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/schema": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-10.1.1.tgz",
+ "integrity": "sha512-24jJghRxEW+SG1lbJ45Zg9HEZ8ZKS923ihbFjOicspFLpryxJkWp6gZJ7D8tCVaSAhli4x1+1wDPAfhT8mLqxg==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-tools/merge": "^9.2.4",
+ "@graphql-tools/utils": "^12.0.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/schema/node_modules/@graphql-tools/utils": {
+ "version": "12.0.1",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-12.0.1.tgz",
+ "integrity": "sha512-8YC6jn4xYDS6YTY6xDAgQAs/nGgvzRaIGHS8GeSfVItQzNK7Ms24CQtE3EJY+amvR+tBnhRSX0N83aGj+V/RIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.1.1",
+ "@whatwg-node/promise-helpers": "^1.0.0",
+ "cross-inspect": "1.0.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-tools/utils": {
+ "version": "11.2.2",
+ "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-11.2.2.tgz",
+ "integrity": "sha512-Do2A/t6ayqOma8m7PvpYMsCBswL+NB35BQSng4GWSBqafL2/BnfAZy/NSENPwV721Rm2fG0BHETFC0+FJJZmLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-typed-document-node/core": "^3.1.1",
+ "@whatwg-node/promise-helpers": "^1.0.0",
+ "cross-inspect": "1.0.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-typed-document-node/core": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
+ "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/@graphql-yoga/logger": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@graphql-yoga/logger/-/logger-2.0.2.tgz",
+ "integrity": "sha512-k2QGHCcH10O+GGe1sgXn0paaP9GCK91wxkuNQaA4IvXBF/RcdBLaX15UeZLW9kMG8kfz7bL0r4Yu+BXYe0JOaw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@graphql-yoga/subscription": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/@graphql-yoga/subscription/-/subscription-5.1.1.tgz",
+ "integrity": "sha512-I48DpyPTxYzmO5TwvKOXxiLI/tsbyeYNhb4/6/oJEBn6nlb8snHL0G0Fw1LC2ApNW1QKYuCPRDTp9yUwVBqhNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@graphql-yoga/typed-event-target": "^3.0.3",
+ "@repeaterjs/repeater": "^3.0.4",
+ "@whatwg-node/events": "^0.1.0",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@graphql-yoga/typed-event-target": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@graphql-yoga/typed-event-target/-/typed-event-target-3.0.3.tgz",
+ "integrity": "sha512-j/EgVfZSzKA2lVeiZ0un8E2eVIqWa9Woyqta4A40ht1M93ui9CDVHU6pmphoQTpFa92BNbMVexXdOtMQk8LCjA==",
+ "license": "MIT",
+ "dependencies": {
+ "@repeaterjs/repeater": "^3.0.4",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.151.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz",
+ "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
+ "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@remix-run/route-pattern": {
+ "version": "0.22.1",
+ "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz",
+ "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==",
+ "license": "MIT"
+ },
+ "node_modules/@repeaterjs/repeater": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.1.0.tgz",
+ "integrity": "sha512-TaoVksZRSx2KWYYpyLQtMQXXeS98VsgZImzW65xmiVgbYhXLk+aEsmzPLirqVuE4/XuUapH2iMtxUzaBNDzdSQ==",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz",
+ "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz",
+ "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz",
+ "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz",
+ "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz",
+ "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz",
+ "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz",
+ "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz",
+ "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz",
+ "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz",
+ "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz",
+ "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz",
+ "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz",
+ "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz",
+ "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz",
+ "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz",
+ "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=22",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=10 <11",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.7",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
+ "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz",
+ "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz",
+ "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/type-utils": "8.71.0",
+ "@typescript-eslint/utils": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.71.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.10",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz",
+ "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz",
+ "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz",
+ "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.71.0",
+ "@typescript-eslint/types": "^8.71.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz",
+ "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz",
+ "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz",
+ "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/utils": "8.71.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz",
+ "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz",
+ "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.71.0",
+ "@typescript-eslint/tsconfig-utils": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/visitor-keys": "8.71.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz",
+ "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.71.0",
+ "@typescript-eslint/types": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz",
+ "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.71.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
+ "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.145.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.2.tgz",
+ "integrity": "sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.2",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz",
+ "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@whatwg-node/disposablestack": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz",
+ "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@whatwg-node/promise-helpers": "^1.0.0",
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/events": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.2.tgz",
+ "integrity": "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/fetch": {
+ "version": "0.10.13",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz",
+ "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@whatwg-node/node-fetch": "^0.8.3",
+ "urlpattern-polyfill": "^10.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/node-fetch": {
+ "version": "0.8.6",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz",
+ "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==",
+ "license": "MIT",
+ "dependencies": {
+ "@fastify/busboy": "^3.1.1",
+ "@whatwg-node/disposablestack": "^0.0.6",
+ "@whatwg-node/promise-helpers": "^1.3.2",
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/promise-helpers": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz",
+ "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@whatwg-node/server": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.11.0.tgz",
+ "integrity": "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@envelop/instrumentation": "^1.0.0",
+ "@whatwg-node/disposablestack": "^0.0.6",
+ "@whatwg-node/fetch": "^0.10.13",
+ "@whatwg-node/promise-helpers": "^1.3.2",
+ "tslib": "^2.6.3"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@wry/caches": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz",
+ "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@wry/context": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz",
+ "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@wry/equality": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz",
+ "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@wry/trie": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz",
+ "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/array-buffer-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
+ "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "is-array-buffer": "^3.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array-includes": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz",
+ "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.2",
+ "es-object-atoms": "^1.1.2",
+ "es-shim-unscopables": "^1.1.0",
+ "is-string": "^1.1.1",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flat": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
+ "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/array.prototype.flatmap": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
+ "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-shim-unscopables": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/arraybuffer.prototype.slice": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
+ "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.1",
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.5",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "is-array-buffer": "^3.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-types-flow": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
+ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/async-function": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
+ "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/axe-core": {
+ "version": "4.13.0",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
+ "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/backend": {
+ "resolved": "backend",
+ "link": true
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.26",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz",
+ "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.29.3",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
+ "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.26",
+ "caniuse-lite": "^1.0.30001813",
+ "electron-to-chromium": "^1.5.439",
+ "node-releases": "^2.0.57",
+ "update-browserslist-db": "^1.3.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
+ "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "get-intrinsic": "^1.3.0",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001813",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001813.tgz",
+ "integrity": "sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
+ "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^7.2.0",
+ "strip-ansi": "^7.1.0",
+ "wrap-ansi": "^9.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concurrently": {
+ "version": "10.0.5",
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz",
+ "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "5.6.2",
+ "rxjs": "7.8.2",
+ "shell-quote": "1.9.0",
+ "supports-color": "10.2.2",
+ "tree-kill": "1.2.2",
+ "yargs": "18.0.0"
+ },
+ "bin": {
+ "conc": "dist/bin/index.js",
+ "concurrently": "dist/bin/index.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "funding": {
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
+ },
+ "node_modules/cross-inspect": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/cross-inspect/-/cross-inspect-1.0.1.tgz",
+ "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/damerau-levenshtein": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
+ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-view-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
+ "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/data-view-byte-length": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz",
+ "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/inspect-js"
+ }
+ },
+ "node_modules/data-view-byte-offset": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz",
+ "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-data-view": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/define-properties": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
+ "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dotenv": {
+ "version": "18.0.4",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.4.tgz",
+ "integrity": "sha512-jGt9uI5BIxpVO15kvy+iEFqNAwxYClKWnkptLjSNPnFLC003Imd0ofr3aEVsbpHdSBdSsbcApNIlz4nbyGMwdA==",
+ "license": "BSD-2-Clause",
+ "bin": {
+ "dotenv": "dist/index.cjs"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.442",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.442.tgz",
+ "integrity": "sha512-najZYZ3+ZpjN1z3VOsrBiv5ej18vQgfV2lvEmxWzfmKrk4bdm3Owseyud4yGU6DfFn9pyunoAHDDyM4KmP78Ew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-abstract": {
+ "version": "1.24.2",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
+ "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-buffer-byte-length": "^1.0.2",
+ "arraybuffer.prototype.slice": "^1.0.4",
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "data-view-buffer": "^1.0.2",
+ "data-view-byte-length": "^1.0.2",
+ "data-view-byte-offset": "^1.0.1",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "es-set-tostringtag": "^2.1.0",
+ "es-to-primitive": "^1.3.0",
+ "function.prototype.name": "^1.1.8",
+ "get-intrinsic": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "get-symbol-description": "^1.1.0",
+ "globalthis": "^1.0.4",
+ "gopd": "^1.2.0",
+ "has-property-descriptors": "^1.0.2",
+ "has-proto": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "internal-slot": "^1.1.0",
+ "is-array-buffer": "^3.0.5",
+ "is-callable": "^1.2.7",
+ "is-data-view": "^1.0.2",
+ "is-negative-zero": "^2.0.3",
+ "is-regex": "^1.2.1",
+ "is-set": "^2.0.3",
+ "is-shared-array-buffer": "^1.0.4",
+ "is-string": "^1.1.1",
+ "is-typed-array": "^1.1.15",
+ "is-weakref": "^1.1.1",
+ "math-intrinsics": "^1.1.0",
+ "object-inspect": "^1.13.4",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.7",
+ "own-keys": "^1.0.1",
+ "regexp.prototype.flags": "^1.5.4",
+ "safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
+ "safe-regex-test": "^1.1.0",
+ "set-proto": "^1.0.0",
+ "stop-iteration-iterator": "^1.1.0",
+ "string.prototype.trim": "^1.2.10",
+ "string.prototype.trimend": "^1.0.9",
+ "string.prototype.trimstart": "^1.0.8",
+ "typed-array-buffer": "^1.0.3",
+ "typed-array-byte-length": "^1.0.3",
+ "typed-array-byte-offset": "^1.0.4",
+ "typed-array-length": "^1.0.7",
+ "unbox-primitive": "^1.1.0",
+ "which-typed-array": "^1.1.19"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-abstract-get": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz",
+ "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.2",
+ "is-callable": "^1.2.7",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-shim-unscopables": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
+ "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-to-primitive": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz",
+ "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-abstract-get": "^1.0.0",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "is-callable": "^1.2.7",
+ "is-date-object": "^1.1.0",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
+ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.6",
+ "@eslint/js": "9.39.5",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-jsx-a11y": {
+ "version": "6.10.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
+ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "aria-query": "^5.3.2",
+ "array-includes": "^3.1.8",
+ "array.prototype.flatmap": "^1.3.2",
+ "ast-types-flow": "^0.0.8",
+ "axe-core": "^4.10.0",
+ "axobject-query": "^4.1.0",
+ "damerau-levenshtein": "^1.0.8",
+ "emoji-regex": "^9.2.2",
+ "hasown": "^2.0.2",
+ "jsx-ast-utils": "^3.3.5",
+ "language-tags": "^1.0.9",
+ "minimatch": "^3.1.2",
+ "object.fromentries": "^2.0.8",
+ "safe-regex-test": "^1.0.3",
+ "string.prototype.includes": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependencies": {
+ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9"
+ }
+ },
+ "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz",
+ "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": "^9 || ^10"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/eslint/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/eslint/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/frontend": {
+ "resolved": "frontend",
+ "link": true
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/function.prototype.name": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz",
+ "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2",
+ "hasown": "^2.0.4",
+ "is-callable": "^1.2.7",
+ "is-document.all": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generator-function": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
+ "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-east-asian-width": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
+ "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-symbol-description": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz",
+ "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
+ "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/globalthis": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
+ "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-properties": "^1.2.1",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graphql": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/graphql/-/graphql-17.0.2.tgz",
+ "integrity": "sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0"
+ }
+ },
+ "node_modules/graphql-tag": {
+ "version": "2.12.7",
+ "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz",
+ "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/graphql-yoga": {
+ "version": "5.24.1",
+ "resolved": "https://registry.npmjs.org/graphql-yoga/-/graphql-yoga-5.24.1.tgz",
+ "integrity": "sha512-NA2aQ4sZvR/bniCCLOlMG6OSZf5hPg3mUf3Phm33ObqRMZEq6WtZrj2hTcHfT2ZEFVV5FRCbcMb4NB1w7BIKaA==",
+ "license": "MIT",
+ "dependencies": {
+ "@envelop/core": "^5.6.1",
+ "@envelop/instrumentation": "^1.0.1",
+ "@graphql-tools/executor": "^2.0.0",
+ "@graphql-tools/schema": "^10.0.11",
+ "@graphql-tools/utils": "^11.2.0",
+ "@graphql-yoga/logger": "^2.0.2",
+ "@graphql-yoga/subscription": "^5.1.1",
+ "@whatwg-node/fetch": "^0.10.6",
+ "@whatwg-node/promise-helpers": "^1.3.2",
+ "@whatwg-node/server": "^0.11.0",
+ "lru-cache": "^10.0.0",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "graphql": "^15.2.0 || ^16.0.0 || ^17.0.0"
+ }
+ },
+ "node_modules/has-bigints": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+ "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-proto": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz",
+ "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
+ "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/husky": {
+ "version": "9.1.7",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
+ "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "husky": "bin.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/typicode"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/internal-slot": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
+ "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "hasown": "^2.0.2",
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-array-buffer": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
+ "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-async-function": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
+ "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "async-function": "^1.0.0",
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-bigint": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz",
+ "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-bigints": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-boolean-object": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
+ "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-data-view": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz",
+ "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "get-intrinsic": "^1.2.6",
+ "is-typed-array": "^1.1.13"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-date-object": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
+ "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-document.all": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
+ "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-finalizationregistry": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
+ "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
+ "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "generator-function": "^2.0.0",
+ "get-proto": "^1.0.1",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-map": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
+ "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-negative-zero": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
+ "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-number-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz",
+ "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-set": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
+ "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-shared-array-buffer": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz",
+ "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-string": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
+ "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-symbol": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz",
+ "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakmap": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
+ "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz",
+ "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-weakset": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz",
+ "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-intrinsic": "^1.2.6"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsdom": {
+ "version": "30.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz",
+ "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^7.0.0",
+ "@asamuzakjp/dom-selector": "^9.2.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.13",
+ "@exodus/bytes": "^1.15.1",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^7.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.2",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.10.2",
+ "w3c-xmlserializer": "^6.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/jsx-ast-utils": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
+ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "array-includes": "^3.1.6",
+ "array.prototype.flat": "^1.3.1",
+ "object.assign": "^4.1.4",
+ "object.values": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/language-subtag-registry": {
+ "version": "0.3.23",
+ "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
+ "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/language-tags": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz",
+ "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "language-subtag-registry": "^0.3.20"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lint-staged": {
+ "version": "17.6.0",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.6.0.tgz",
+ "integrity": "sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^4.0.7",
+ "string-argv": "^0.3.2",
+ "tinyexec": "^1.3.1"
+ },
+ "bin": {
+ "lint-staged": "bin/lint-staged.js"
+ },
+ "engines": {
+ "node": ">=22.22.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/lint-staged"
+ },
+ "optionalDependencies": {
+ "yaml": "^2.9.1"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
+ "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.57",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz",
+ "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/object.assign": {
+ "version": "4.1.7",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
+ "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0",
+ "has-symbols": "^1.1.0",
+ "object-keys": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.fromentries": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
+ "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.2",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/object.values": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
+ "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.3",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optimism": {
+ "version": "0.18.1",
+ "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz",
+ "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@wry/caches": "^1.0.0",
+ "@wry/context": "^0.7.0",
+ "@wry/trie": "^0.5.0",
+ "tslib": "^2.3.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/own-keys": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
+ "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.4",
+ "get-intrinsic": "^1.3.0",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.23.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
+ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
+ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
+ "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.63.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.63.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+ "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.9",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
+ "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react-router": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz",
+ "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/route-pattern": "^0.22.1",
+ "cookie-es": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=22.22.0"
+ },
+ "peerDependencies": {
+ "react": ">=19.2.7",
+ "react-dom": ">=19.2.7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/reflect.getprototypeof": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
+ "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.9",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0",
+ "get-intrinsic": "^1.2.7",
+ "get-proto": "^1.0.1",
+ "which-builtin-type": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/regexp.prototype.flags": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
+ "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "define-properties": "^1.2.1",
+ "es-errors": "^1.3.0",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "set-function-name": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz",
+ "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.151.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.11",
+ "@rolldown/binding-android-arm64": "1.2.11",
+ "@rolldown/binding-darwin-arm64": "1.2.11",
+ "@rolldown/binding-darwin-x64": "1.2.11",
+ "@rolldown/binding-freebsd-x64": "1.2.11",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.11",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.11",
+ "@rolldown/binding-linux-arm64-musl": "1.2.11",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.11",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.11",
+ "@rolldown/binding-linux-x64-gnu": "1.2.11",
+ "@rolldown/binding-linux-x64-musl": "1.2.11",
+ "@rolldown/binding-openharmony-arm64": "1.2.11",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.11",
+ "@rolldown/binding-win32-x64-msvc": "1.2.11"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safe-array-concat": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz",
+ "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "get-intrinsic": "^1.3.0",
+ "has-symbols": "^1.1.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">=0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-function-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
+ "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "functions-have-names": "^1.2.3",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/set-proto": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz",
+ "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shell-quote": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
+ "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stop-iteration-iterator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
+ "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "internal-slot": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string-argv": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
+ "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6.19"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
+ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^10.3.0",
+ "get-east-asian-width": "^1.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width/node_modules/emoji-regex": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
+ "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string.prototype.includes": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
+ "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.23.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/string.prototype.trim": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz",
+ "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "define-data-property": "^1.1.4",
+ "define-properties": "^1.2.1",
+ "es-abstract": "^1.24.2",
+ "es-object-atoms": "^1.1.2",
+ "has-property-descriptors": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimend": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz",
+ "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/string.prototype.trimstart": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz",
+ "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.7",
+ "define-properties": "^1.2.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi/node_modules/ansi-regex": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
+ "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
+ "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.16",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz",
+ "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.16"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.16",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz",
+ "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/tree-kill": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "tree-kill": "cli.js"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/tsx": {
+ "version": "4.23.15",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
+ "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typed-array-buffer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
+ "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "es-errors": "^1.3.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/typed-array-byte-length": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz",
+ "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.8",
+ "for-each": "^0.3.3",
+ "gopd": "^1.2.0",
+ "has-proto": "^1.2.0",
+ "is-typed-array": "^1.1.14"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-byte-offset": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz",
+ "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.9",
+ "for-each": "^0.3.5",
+ "gopd": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "reflect.getprototypeof": "^1.0.10"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typed-array-length": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz",
+ "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bind": "^1.0.9",
+ "for-each": "^0.3.5",
+ "gopd": "^1.2.0",
+ "is-typed-array": "^1.1.15",
+ "possible-typed-array-names": "^1.1.0",
+ "reflect.getprototypeof": "^1.0.10"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.71.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.0.tgz",
+ "integrity": "sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.71.0",
+ "@typescript-eslint/parser": "8.71.0",
+ "@typescript-eslint/typescript-estree": "8.71.0",
+ "@typescript-eslint/utils": "8.71.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.11.2",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
+ "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/urlpattern-polyfill": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
+ "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
+ "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.9",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.2.tgz",
+ "integrity": "sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.2",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "^6.1.4",
+ "tinyexec": "^1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "^3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.2",
+ "@vitest/browser-preview": "5.0.2",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.2",
+ "@vitest/coverage-v8": "5.0.2",
+ "@vitest/ui": "5.0.2",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+ "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.1.2",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz",
+ "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/which-boxed-primitive": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
+ "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-bigint": "^1.1.0",
+ "is-boolean-object": "^1.2.1",
+ "is-number-object": "^1.1.1",
+ "is-string": "^1.1.1",
+ "is-symbol": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-builtin-type": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz",
+ "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "function.prototype.name": "^1.1.6",
+ "has-tostringtag": "^1.0.2",
+ "is-async-function": "^2.0.0",
+ "is-date-object": "^1.1.0",
+ "is-finalizationregistry": "^1.1.0",
+ "is-generator-function": "^1.0.10",
+ "is-regex": "^1.2.1",
+ "is-weakref": "^1.0.2",
+ "isarray": "^2.0.5",
+ "which-boxed-primitive": "^1.1.0",
+ "which-collection": "^1.0.2",
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-collection": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz",
+ "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-map": "^2.0.3",
+ "is-set": "^2.0.3",
+ "is-weakmap": "^2.0.2",
+ "is-weakset": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.24",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz",
+ "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.9",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.2.tgz",
+ "integrity": "sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
+ "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.2.1",
+ "string-width": "^7.0.0",
+ "strip-ansi": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yaml": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "18.0.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
+ "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^9.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "string-width": "^7.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^22.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "22.0.0",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
+ "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=23"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.6.5",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
+ "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..15462dc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "it2810-project2",
+ "private": true,
+ "type": "module",
+ "workspaces": [
+ "backend",
+ "frontend"
+ ],
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "dev": "concurrently -n backend,frontend -c blue,magenta \"npm run dev -w backend\" \"npm run dev -w frontend\"",
+ "build": "npm run build -w backend && npm run build -w frontend",
+ "lint": "eslint . && prettier --check .",
+ "format": "prettier --write .",
+ "typecheck": "npm run typecheck --workspaces",
+ "test": "npm test --workspaces",
+ "test:e2e": "playwright test",
+ "db:migrate": "npm run db:migrate -w backend",
+ "db:seed": "npm run db:seed -w backend",
+ "prepare": "husky"
+ },
+ "lint-staged": {
+ "*.{ts,tsx,js,mjs,cjs}": [
+ "eslint --fix",
+ "prettier --write"
+ ],
+ "*.{json,md,css,yml,yaml,html}": "prettier --write"
+ },
+ "devDependencies": {
+ "@axe-core/playwright": "^4.13.0",
+ "@eslint/js": "^9.39.5",
+ "@playwright/test": "^1.63.0",
+ "concurrently": "^10.0.5",
+ "eslint": "^9.39.5",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "eslint-plugin-react-refresh": "^0.5.7",
+ "globals": "^17.12.0",
+ "husky": "^9.1.7",
+ "lint-staged": "^17.6.0",
+ "prettier": "^3.9.9",
+ "typescript": "~6.0",
+ "typescript-eslint": "^8.71.0"
+ }
+}
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000..03be4b7
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ "noFallthroughCasesInSwitch": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true
+ }
+}
From 19cd994af8c559ce64a7677f89ade4a4d204d399 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:09:27 +0000
Subject: [PATCH 02/29] docs: add deployment guide, systemd unit and Apache
config
---
deploy/apache-project2.conf | 42 ++++++++++++++++++++++
deploy/project2-backend.service | 30 ++++++++++++++++
docs/deploy.md | 64 +++++++++++++++++++++++++++++++++
3 files changed, 136 insertions(+)
create mode 100644 deploy/apache-project2.conf
create mode 100644 deploy/project2-backend.service
create mode 100644 docs/deploy.md
diff --git a/deploy/apache-project2.conf b/deploy/apache-project2.conf
new file mode 100644
index 0000000..cddb606
--- /dev/null
+++ b/deploy/apache-project2.conf
@@ -0,0 +1,42 @@
+# Apache-konfig for http://it2810-17.idi.ntnu.no/project2/
+# Legg fila i /etc/apache2/conf-available/ og aktiver med:
+# sudo a2enmod proxy proxy_http rewrite headers expires deflate
+# sudo a2enconf apache-project2 && sudo systemctl reload apache2
+
+# GraphQL-API: videresend til Node-backenden, som bare lytter lokalt.
+ProxyPreserveHost On
+ProxyPass /project2/graphql http://127.0.0.1:3001/graphql
+ProxyPassReverse /project2/graphql http://127.0.0.1:3001/graphql
+
+Alias /project2 /var/www/html/project2
+
+
+ Options -Indexes
+ AllowOverride None
+ Require all granted
+
+ # SPA: ukjente stier (f.eks. /project2/title/tt0111161) skal gi index.html slik at
+ # React Router kan håndtere dem ved direkte lasting og refresh.
+ RewriteEngine On
+ RewriteBase /project2/
+ RewriteCond %{REQUEST_FILENAME} !-f
+ RewriteCond %{REQUEST_FILENAME} !-d
+ RewriteRule ^ index.html [L]
+
+
+# Komprimer tekstressurser – store besparelser på JS/CSS/JSON for lite CPU.
+
+ AddOutputFilterByType DEFLATE text/html text/css text/plain application/javascript application/json image/svg+xml
+
+
+# Vite gir filene i assets/ innholdshash i navnet, så de kan caches «for alltid».
+# index.html må derimot alltid revalideres, ellers får brukerne gammel kode etter deploy.
+
+ Header set Cache-Control "public, max-age=31536000, immutable"
+
+
+ Header set Cache-Control "no-cache"
+
+
+ Header set Cache-Control "no-store"
+
diff --git a/deploy/project2-backend.service b/deploy/project2-backend.service
new file mode 100644
index 0000000..2fad3b6
--- /dev/null
+++ b/deploy/project2-backend.service
@@ -0,0 +1,30 @@
+# systemd-enhet for GraphQL-backenden på it2810-17.idi.ntnu.no.
+# Installeres med: sudo cp deploy/project2-backend.service /etc/systemd/system/
+# sudo systemctl daemon-reload && sudo systemctl enable --now project2-backend
+[Unit]
+Description=IT2810 gruppe 17 prosjekt 2 – GraphQL-backend
+# Backenden er ubrukelig uten databasen, så vent på at Postgres er oppe.
+After=network.target postgresql.service
+Wants=postgresql.service
+
+[Service]
+Type=simple
+# Egen systembruker uten innlogging begrenser skaden hvis prosessen kompromitteres.
+User=project2
+Group=project2
+WorkingDirectory=/opt/project2/backend
+EnvironmentFile=/opt/project2/.env
+Environment=NODE_ENV=production
+ExecStart=/usr/bin/node dist/src/index.js
+Restart=on-failure
+RestartSec=5
+
+# Herding: prosessen trenger bare å lese koden og snakke med Postgres over nettverket.
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadOnlyPaths=/opt/project2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/docs/deploy.md b/docs/deploy.md
new file mode 100644
index 0000000..3e10417
--- /dev/null
+++ b/docs/deploy.md
@@ -0,0 +1,64 @@
+# Deploy til it2810-17.idi.ntnu.no
+
+Stegene under kjøres av Nicolay på VM-en (krever NTNU-VPN og `sudo`). Agentene har ikke kjørt dem.
+Ferdige filer ligger i `deploy/`.
+
+## 1. PostgreSQL
+
+```bash
+sudo apt update && sudo apt install -y postgresql postgresql-contrib # contrib gir pg_trgm
+sudo -u postgres createuser project2 --pwprompt # velg et sterkt passord
+sudo -u postgres createdb project2 --owner project2
+# pg_trgm må opprettes av en superbruker første gang (migreringen bruker IF NOT EXISTS):
+sudo -u postgres psql -d project2 -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'
+```
+
+## 2. Kode, bygg og data
+
+```bash
+sudo useradd --system --no-create-home --shell /usr/sbin/nologin project2
+sudo mkdir -p /opt/project2 && sudo chown "$USER" /opt/project2
+git clone /opt/project2 && cd /opt/project2
+npm ci
+cp .env.example .env # sett DATABASE_URL=postgres://project2:@localhost:5432/project2
+chmod 600 .env
+npm run build
+npm run db:migrate
+
+# Ekte IMDb-data (ca. 250 MB nedlasting):
+wget -P data https://datasets.imdbws.com/title.basics.tsv.gz https://datasets.imdbws.com/title.ratings.tsv.gz
+npm run db:seed
+sudo chown -R project2:project2 /opt/project2
+sudo chmod 600 /opt/project2/.env
+```
+
+## 3. Backend som tjeneste
+
+```bash
+sudo cp deploy/project2-backend.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now project2-backend
+curl -s -X POST localhost:3001/graphql -H 'content-type: application/json' \
+ -d '{"query":"{ search(first: 1) { totalCount } }"}'
+```
+
+## 4. Apache
+
+```bash
+sudo mkdir -p /var/www/html/project2
+sudo cp -r frontend/dist/* /var/www/html/project2/
+sudo cp deploy/apache-project2.conf /etc/apache2/conf-available/
+sudo a2enmod proxy proxy_http rewrite headers expires deflate
+sudo a2enconf apache-project2
+sudo apachectl configtest && sudo systemctl reload apache2
+```
+
+Åpne .
+
+## Oppdatering senere
+
+```bash
+cd /opt/project2 && git pull && npm ci && npm run build && npm run db:migrate
+sudo cp -r frontend/dist/* /var/www/html/project2/
+sudo systemctl restart project2-backend
+```
From 6fc95673f509df912cfc468fad47fc5e7c8cc341 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:09:36 +0000
Subject: [PATCH 03/29] docs: define GraphQL API contract
---
docs/api.md | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 143 insertions(+)
create mode 100644 docs/api.md
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..1e147a5
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,143 @@
+# GraphQL-API – kontrakt
+
+Denne fila er kontrakten mellom backend og frontend. Endringer skal gjøres her først.
+
+- Endepunkt: `POST /graphql` på port 3001. I produksjon og i Vite-dev når frontend det via
+ `/project2/graphql`.
+- Anonym bruker: frontend lager en UUID v4 én gang, lagrer den i `localStorage` og sender den i
+ headeren `x-user-id` på alle requests. Backend avviser ugyldige UUID-er (behandles som «ingen
+ bruker»). Mutations krever gyldig `x-user-id`.
+
+```graphql
+enum TitleType {
+ MOVIE
+ SERIES
+}
+
+enum SortField {
+ RELEVANCE # likhet med søketeksten; uten søketekst: flest stemmer først
+ RATING
+ YEAR
+ TITLE
+}
+
+enum SortDirection {
+ ASC
+ DESC
+}
+
+input SortInput {
+ field: SortField!
+ # Standard: RELEVANCE DESC, RATING DESC, YEAR DESC, TITLE ASC
+ direction: SortDirection
+}
+
+input SearchFilters {
+ genres: [String!] # tittelen må ha ALLE valgte sjangre
+ decades: [Int!] # f.eks. 1990 = 1990–1999; flere tiår = ELLER
+ types: [TitleType!] # flere typer = ELLER
+ minRating: Float # 0–10
+}
+
+type PageInfo {
+ hasNextPage: Boolean!
+ endCursor: String
+}
+
+type Review {
+ id: ID!
+ titleId: ID!
+ author: String! # visningsnavn, 1–50 tegn
+ rating: Int! # 1–5
+ text: String! # 0–2000 tegn
+ createdAt: String! # ISO 8601
+ isMine: Boolean! # skrevet av x-user-id
+}
+
+type ReviewEdge {
+ cursor: String!
+ node: Review!
+}
+
+type ReviewConnection {
+ edges: [ReviewEdge!]!
+ pageInfo: PageInfo!
+ totalCount: Int!
+}
+
+type Title {
+ id: ID! # IMDb tconst, f.eks. "tt0111161"
+ primaryTitle: String!
+ originalTitle: String!
+ type: TitleType!
+ startYear: Int
+ endYear: Int
+ runtimeMinutes: Int
+ genres: [String!]!
+ averageRating: Float # IMDb-snitt 1–10
+ numVotes: Int!
+ userRating: Float # snitt av anmeldelser i vår app (1–5), null uten anmeldelser
+ reviewCount: Int!
+ inMyList: Boolean!
+ reviews(first: Int = 10, after: String): ReviewConnection! # nyeste først
+}
+
+type TitleEdge {
+ cursor: String!
+ node: Title!
+}
+
+type TitleConnection {
+ edges: [TitleEdge!]!
+ pageInfo: PageInfo!
+ totalCount: Int!
+}
+
+type FacetCount {
+ value: String! # sjangernavn, tiår som tekst ("1990") eller TitleType-navn ("MOVIE")
+ count: Int!
+}
+
+type Facets {
+ genres: [FacetCount!]!
+ decades: [FacetCount!]!
+ types: [FacetCount!]!
+}
+
+input AddReviewInput {
+ titleId: ID!
+ author: String!
+ rating: Int!
+ text: String!
+}
+
+type Query {
+ # query: tom/null = alle titler. Case-insensitivt delstrengsøk i primær- og originaltittel.
+ search(
+ query: String
+ filters: SearchFilters
+ sort: SortInput
+ first: Int = 20
+ after: String
+ ): TitleConnection!
+ title(id: ID!): Title
+ # Antall treff per verdi. Hver dimensjon telles med alle ANDRE filtre aktive (vanlig
+ # fasettsøk), slik at brukeren ser hva hun får ved å legge til/bytte en verdi.
+ facets(query: String, filters: SearchFilters): Facets!
+ myList(first: Int = 20, after: String): TitleConnection! # sist lagt til først
+ genres: [String!]! # alle sjangre, alfabetisk
+}
+
+type Mutation {
+ addReview(input: AddReviewInput!): Review!
+ toggleList(titleId: ID!): Title! # returnerer tittelen med oppdatert inMyList
+}
+```
+
+## Regler
+
+- `first` må være 1–50, ellers `BAD_USER_INPUT`. `query` maks 200 tegn. Spørredybde maks 6.
+- Cursorer er ugjennomsiktige strenger (base64). Ugyldig cursor gir `BAD_USER_INPUT`.
+- Feil returneres som GraphQL-feil med `extensions.code`: `BAD_USER_INPUT`, `NOT_FOUND`,
+ `UNAUTHENTICATED` (mangler `x-user-id` på mutation), `INTERNAL_SERVER_ERROR` (uten detaljer).
+- All søk, filtrering, sortering og paginering skjer i SQL.
From e329ce1da9c89760a35dfb293087e12e9c6ed66b Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:09:59 +0000
Subject: [PATCH 04/29] test: add Playwright configuration
---
playwright.config.ts | 43 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
create mode 100644 playwright.config.ts
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..af8db45
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,43 @@
+import { defineConfig, devices } from '@playwright/test';
+
+// E2E-testene kjører mot en egen database slik at anmeldelser og lister de lager ikke
+// blander seg med utviklingsdata. `e2e/global-setup.ts` migrerer og fyller den.
+const E2E_DATABASE_URL =
+ process.env.E2E_DATABASE_URL ?? 'postgres://postgres@localhost:5432/project2_e2e';
+
+// I miljøer med en forhåndsinstallert Chromium (f.eks. CI-bilder) kan stien overstyres.
+const executablePath = process.env.PW_CHROMIUM_PATH || undefined;
+
+export default defineConfig({
+ testDir: './e2e',
+ globalSetup: './e2e/global-setup.ts',
+ fullyParallel: false,
+ workers: 1,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? 'list' : [['list'], ['html', { open: 'never' }]],
+ use: {
+ baseURL: 'http://localhost:4173/project2/',
+ trace: 'retain-on-failure',
+ launchOptions: { executablePath },
+ },
+ projects: [
+ { name: 'desktop', use: { ...devices['Desktop Chrome'] } },
+ { name: 'mobil', use: { ...devices['Pixel 7'] } },
+ ],
+ webServer: [
+ {
+ command: 'npm run build -w backend && npm run start -w backend',
+ url: 'http://localhost:3001/graphql?query=%7B__typename%7D',
+ env: { DATABASE_URL: E2E_DATABASE_URL, PORT: '3001', NODE_ENV: 'test' },
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ {
+ command:
+ 'npm run build -w frontend && npm run preview -w frontend -- --port 4173 --strictPort',
+ url: 'http://localhost:4173/project2/',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ ],
+});
From b0cfb744b3f27364bc23861f0b1a75d7a9d507e8 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:19:20 +0000
Subject: [PATCH 05/29] chore: add one-shot VM setup script
---
deploy/setup-vm.sh | 87 ++++++++++++++++++++++++++++++++++++++++++++++
docs/deploy.md | 3 ++
2 files changed, 90 insertions(+)
create mode 100755 deploy/setup-vm.sh
diff --git a/deploy/setup-vm.sh b/deploy/setup-vm.sh
new file mode 100755
index 0000000..a9930a3
--- /dev/null
+++ b/deploy/setup-vm.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# Setter opp hele prosjektet på it2810-17.idi.ntnu.no (Ubuntu) i én kjøring.
+# Kjøres fra roten av en klon av repoet: bash deploy/setup-vm.sh
+# Trygt å kjøre på nytt: hvert steg hopper over det som allerede er gjort.
+set -euo pipefail
+
+APP_DIR=/opt/project2
+WEB_DIR=/var/www/html/project2
+DB_NAME=project2
+DB_USER=project2
+REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+
+step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
+
+step "Installerer systempakker (Postgres, Apache, Node 22)"
+sudo apt-get update -qq
+sudo apt-get install -y -qq postgresql postgresql-contrib apache2 curl rsync
+if ! command -v node >/dev/null || [ "$(node -p 'process.versions.node.split(".")[0]')" -lt 20 ]; then
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+ sudo apt-get install -y -qq nodejs
+fi
+
+step "Oppretter systembruker og kopierer koden til $APP_DIR"
+id -u project2 >/dev/null 2>&1 || sudo useradd --system --no-create-home --shell /usr/sbin/nologin project2
+sudo mkdir -p "$APP_DIR"
+sudo chown -R "$USER" "$APP_DIR"
+# Kopien gjør at tjenesten ikke er avhengig av hjemmemappa til den som kjørte skriptet.
+rsync -a --delete --exclude node_modules --exclude .env --exclude 'data/*.gz' "$REPO_DIR/" "$APP_DIR/"
+cd "$APP_DIR"
+
+step "Oppretter database og bruker"
+if [ ! -f .env ]; then
+ # Passordet genereres her og lagres bare i .env på VM-en – aldri i git.
+ DB_PASS="$(openssl rand -hex 24)"
+ sudo -u postgres psql -v ON_ERROR_STOP=1 -q < .env
+ echo "NODE_ENV=production" >> .env
+ chmod 600 .env
+fi
+sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname = '$DB_NAME'" | grep -q 1 \
+ || sudo -u postgres createdb "$DB_NAME" --owner "$DB_USER"
+# pg_trgm krever superbruker første gang; migreringen bruker IF NOT EXISTS.
+sudo -u postgres psql -d "$DB_NAME" -qc 'CREATE EXTENSION IF NOT EXISTS pg_trgm;'
+
+step "Installerer avhengigheter og bygger"
+HUSKY=0 npm ci --no-audit --no-fund
+npm run build
+npm run db:migrate
+
+step "Laster ned IMDb-data og importerer (tar noen minutter)"
+mkdir -p data
+for f in title.basics.tsv.gz title.ratings.tsv.gz; do
+ # -z: last bare ned på nytt hvis IMDb har en nyere fil enn den vi har.
+ curl -fsSL -z "data/$f" -o "data/$f" "https://datasets.imdbws.com/$f"
+done
+npm run db:seed
+
+step "Starter backend som systemd-tjeneste"
+sudo chown -R project2:project2 "$APP_DIR"
+sudo cp deploy/project2-backend.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable --now project2-backend
+sudo systemctl restart project2-backend
+
+step "Publiserer frontend og konfigurerer Apache"
+sudo mkdir -p "$WEB_DIR"
+sudo rsync -a --delete frontend/dist/ "$WEB_DIR/"
+sudo cp deploy/apache-project2.conf /etc/apache2/conf-available/
+sudo a2enmod -q proxy proxy_http rewrite headers expires deflate
+sudo a2enconf -q apache-project2
+sudo apachectl configtest
+sudo systemctl reload apache2
+
+step "Sjekker at alt svarer"
+sleep 2
+curl -fsS -X POST http://localhost/project2/graphql -H 'content-type: application/json' \
+ -d '{"query":"{ search(first: 1) { totalCount } }"}' && echo
+echo "Ferdig! Åpne http://it2810-17.idi.ntnu.no/project2/"
diff --git a/docs/deploy.md b/docs/deploy.md
index 3e10417..e2b7c09 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -3,6 +3,9 @@
Stegene under kjøres av Nicolay på VM-en (krever NTNU-VPN og `sudo`). Agentene har ikke kjørt dem.
Ferdige filer ligger i `deploy/`.
+**Raskeste vei:** `bash deploy/setup-vm.sh` fra en klon av repoet på VM-en gjør alle stegene under
+automatisk (genererer også databasepassord i `.env`). Stegene under er det samme, gjort for hånd.
+
## 1. PostgreSQL
```bash
From 7eec93b0faaf1ce8a355916ed485527693c67e80 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:24:21 +0000
Subject: [PATCH 06/29] feat(frontend): add search, filters, detail page,
reviews and my list
---
frontend/src/apollo/cache.ts | 27 +
frontend/src/apollo/client.test.ts | 53 ++
frontend/src/apollo/client.ts | 19 +
frontend/src/apollo/userId.ts | 23 +
frontend/src/components/ActiveFilters.tsx | 55 ++
frontend/src/components/ErrorMessage.tsx | 17 +
frontend/src/components/FilterPanel.tsx | 149 +++++
frontend/src/components/Layout.tsx | 56 ++
frontend/src/components/ListToggleButton.tsx | 48 ++
frontend/src/components/ResultSkeleton.tsx | 13 +
frontend/src/components/ReviewForm.test.tsx | 229 +++++++
frontend/src/components/ReviewForm.tsx | 184 ++++++
frontend/src/components/ReviewList.tsx | 28 +
frontend/src/components/SearchBox.test.tsx | 133 ++++
frontend/src/components/SearchBox.tsx | 83 +++
.../src/components/SearchResults.test.tsx | 199 ++++++
frontend/src/components/SearchResults.tsx | 128 ++++
frontend/src/components/SortControls.tsx | 55 ++
frontend/src/components/Stars.tsx | 9 +
frontend/src/components/TitleCard.tsx | 44 ++
frontend/src/graphql/operations.ts | 150 +++++
frontend/src/graphql/types.ts | 126 ++++
frontend/src/hooks/useDebouncedCallback.ts | 24 +
frontend/src/hooks/useDocumentTitle.ts | 8 +
frontend/src/hooks/useInfiniteScroll.ts | 32 +
frontend/src/hooks/useSearchState.ts | 23 +
frontend/src/lib/format.ts | 32 +
frontend/src/lib/searchState.test.ts | 63 ++
frontend/src/lib/searchState.ts | 119 ++++
frontend/src/lib/validateReview.ts | 27 +
frontend/src/main.tsx | 17 +-
frontend/src/pages/HomePage.test.tsx | 198 ++++++
frontend/src/pages/HomePage.tsx | 28 +
frontend/src/pages/MyListPage.test.tsx | 74 +++
frontend/src/pages/MyListPage.tsx | 136 +++++
frontend/src/pages/NotFoundPage.tsx | 15 +
frontend/src/pages/RouteError.tsx | 14 +
frontend/src/pages/TitlePage.test.tsx | 247 ++++++++
frontend/src/pages/TitlePage.tsx | 169 +++++
frontend/src/routes.tsx | 25 +
frontend/src/styles/global.css | 577 ++++++++++++++++++
frontend/src/test/utils.tsx | 180 ++++++
42 files changed, 3833 insertions(+), 3 deletions(-)
create mode 100644 frontend/src/apollo/cache.ts
create mode 100644 frontend/src/apollo/client.test.ts
create mode 100644 frontend/src/apollo/client.ts
create mode 100644 frontend/src/apollo/userId.ts
create mode 100644 frontend/src/components/ActiveFilters.tsx
create mode 100644 frontend/src/components/ErrorMessage.tsx
create mode 100644 frontend/src/components/FilterPanel.tsx
create mode 100644 frontend/src/components/Layout.tsx
create mode 100644 frontend/src/components/ListToggleButton.tsx
create mode 100644 frontend/src/components/ResultSkeleton.tsx
create mode 100644 frontend/src/components/ReviewForm.test.tsx
create mode 100644 frontend/src/components/ReviewForm.tsx
create mode 100644 frontend/src/components/ReviewList.tsx
create mode 100644 frontend/src/components/SearchBox.test.tsx
create mode 100644 frontend/src/components/SearchBox.tsx
create mode 100644 frontend/src/components/SearchResults.test.tsx
create mode 100644 frontend/src/components/SearchResults.tsx
create mode 100644 frontend/src/components/SortControls.tsx
create mode 100644 frontend/src/components/Stars.tsx
create mode 100644 frontend/src/components/TitleCard.tsx
create mode 100644 frontend/src/graphql/operations.ts
create mode 100644 frontend/src/graphql/types.ts
create mode 100644 frontend/src/hooks/useDebouncedCallback.ts
create mode 100644 frontend/src/hooks/useDocumentTitle.ts
create mode 100644 frontend/src/hooks/useInfiniteScroll.ts
create mode 100644 frontend/src/hooks/useSearchState.ts
create mode 100644 frontend/src/lib/format.ts
create mode 100644 frontend/src/lib/searchState.test.ts
create mode 100644 frontend/src/lib/searchState.ts
create mode 100644 frontend/src/lib/validateReview.ts
create mode 100644 frontend/src/pages/HomePage.test.tsx
create mode 100644 frontend/src/pages/HomePage.tsx
create mode 100644 frontend/src/pages/MyListPage.test.tsx
create mode 100644 frontend/src/pages/MyListPage.tsx
create mode 100644 frontend/src/pages/NotFoundPage.tsx
create mode 100644 frontend/src/pages/RouteError.tsx
create mode 100644 frontend/src/pages/TitlePage.test.tsx
create mode 100644 frontend/src/pages/TitlePage.tsx
create mode 100644 frontend/src/routes.tsx
create mode 100644 frontend/src/styles/global.css
create mode 100644 frontend/src/test/utils.tsx
diff --git a/frontend/src/apollo/cache.ts b/frontend/src/apollo/cache.ts
new file mode 100644
index 0000000..bc336cf
--- /dev/null
+++ b/frontend/src/apollo/cache.ts
@@ -0,0 +1,27 @@
+import { InMemoryCache } from '@apollo/client';
+import { relayStylePagination } from '@apollo/client/utilities';
+
+/**
+ * keyArgs bestemmer hva som gir egen cache-oppføring. Sortering og filtre
+ * hører med (ulike resultater), mens `first`/`after` ikke gjør det: sidene
+ * for ett søk slås sammen til én liste. Det gjør at «tilbake» viser alle
+ * innlastede sider uten ny request.
+ */
+export function createCache() {
+ return new InMemoryCache({
+ typePolicies: {
+ Query: {
+ fields: {
+ search: relayStylePagination(['query', 'filters', 'sort']),
+ myList: relayStylePagination(false),
+ },
+ },
+ Title: {
+ fields: {
+ // Anmeldelsene tilhører én tittel; sidene slås sammen, og ny første side erstatter lista.
+ reviews: relayStylePagination(false),
+ },
+ },
+ },
+ });
+}
diff --git a/frontend/src/apollo/client.test.ts b/frontend/src/apollo/client.test.ts
new file mode 100644
index 0000000..caad21a
--- /dev/null
+++ b/frontend/src/apollo/client.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { GENRES_QUERY } from '../graphql/operations';
+import { createClient } from './client';
+import { getUserId } from './userId';
+
+const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+
+describe('anonym bruker-id', () => {
+ beforeEach(() => localStorage.clear());
+
+ it('lager en UUID v4 og gjenbruker den', () => {
+ const id = getUserId();
+ expect(id).toMatch(UUID_V4);
+ expect(getUserId()).toBe(id);
+ });
+
+ it('erstatter en ugyldig lagret verdi', () => {
+ localStorage.setItem('it2810.userId', 'ikke-en-uuid');
+ expect(getUserId()).toMatch(UUID_V4);
+ });
+
+ it('virker også når localStorage kaster', () => {
+ const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
+ throw new Error('blokkert');
+ });
+ const id = getUserId();
+ expect(id).toMatch(UUID_V4);
+ expect(getUserId()).toBe(id);
+ spy.mockRestore();
+ });
+});
+
+describe('Apollo-klient', () => {
+ beforeEach(() => localStorage.clear());
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('sender x-user-id på alle requests', async () => {
+ const fetchMock = vi.fn(async () =>
+ Response.json(
+ { data: { genres: ['Drama'] } },
+ { headers: { 'content-type': 'application/json' } },
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+
+ await createClient().query({ query: GENRES_QUERY });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
+ expect(url).toBe(import.meta.env.BASE_URL + 'graphql');
+ expect((init.headers as Record)['x-user-id']).toBe(getUserId());
+ });
+});
diff --git a/frontend/src/apollo/client.ts b/frontend/src/apollo/client.ts
new file mode 100644
index 0000000..62e382d
--- /dev/null
+++ b/frontend/src/apollo/client.ts
@@ -0,0 +1,19 @@
+import { ApolloClient, HttpLink } from '@apollo/client';
+import { ApolloLink } from '@apollo/client';
+import { SetContextLink } from '@apollo/client/link/context';
+import { createCache } from './cache';
+import { getUserId } from './userId';
+
+const uri = import.meta.env.VITE_GRAPHQL_URL || import.meta.env.BASE_URL + 'graphql';
+
+// Leses per request slik at id-en alltid finnes, også hvis lagringen ble tømt underveis.
+const userIdLink = new SetContextLink((prev) => ({
+ headers: { ...prev.headers, 'x-user-id': getUserId() },
+}));
+
+export function createClient() {
+ return new ApolloClient({
+ cache: createCache(),
+ link: ApolloLink.from([userIdLink, new HttpLink({ uri })]),
+ });
+}
diff --git a/frontend/src/apollo/userId.ts b/frontend/src/apollo/userId.ts
new file mode 100644
index 0000000..71dc275
--- /dev/null
+++ b/frontend/src/apollo/userId.ts
@@ -0,0 +1,23 @@
+const STORAGE_KEY = 'it2810.userId';
+
+const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+let memoryFallback: string | null = null;
+
+/**
+ * Anonym bruker-id. Lagres i localStorage slik at «Min liste» og egne
+ * anmeldelser overlever omlasting. Faller tilbake til minnet hvis lagring
+ * er blokkert (privat modus), så appen fortsatt virker i økta.
+ */
+export function getUserId(): string {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored && UUID_V4.test(stored)) return stored;
+ const id = crypto.randomUUID();
+ localStorage.setItem(STORAGE_KEY, id);
+ return id;
+ } catch {
+ memoryFallback ??= crypto.randomUUID();
+ return memoryFallback;
+ }
+}
diff --git a/frontend/src/components/ActiveFilters.tsx b/frontend/src/components/ActiveFilters.tsx
new file mode 100644
index 0000000..0491a71
--- /dev/null
+++ b/frontend/src/components/ActiveFilters.tsx
@@ -0,0 +1,55 @@
+import { decadeLabel, typeLabel } from '../lib/format';
+import { activeFilterCount, type SearchState } from '../lib/searchState';
+
+interface Props {
+ state: SearchState;
+ onChange: (patch: Partial) => void;
+ onReset: () => void;
+}
+
+export function ActiveFilters({ state, onChange, onReset }: Props) {
+ if (activeFilterCount(state) === 0) return null;
+
+ const chips: { key: string; label: string; remove: () => void }[] = [
+ ...state.genres.map((g) => ({
+ key: `g-${g}`,
+ label: g,
+ remove: () => onChange({ genres: state.genres.filter((x) => x !== g) }),
+ })),
+ ...state.decades.map((d) => ({
+ key: `d-${d}`,
+ label: decadeLabel(d),
+ remove: () => onChange({ decades: state.decades.filter((x) => x !== d) }),
+ })),
+ ...state.types.map((t) => ({
+ key: `t-${t}`,
+ label: typeLabel(t),
+ remove: () => onChange({ types: state.types.filter((x) => x !== t) }),
+ })),
+ ];
+ if (state.minRating !== null) {
+ chips.push({
+ key: 'rating',
+ label: `Rating minst ${state.minRating}`,
+ remove: () => onChange({ minRating: null }),
+ });
+ }
+
+ return (
+
+
+ {chips.map((c) => (
+ -
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ErrorMessage.tsx b/frontend/src/components/ErrorMessage.tsx
new file mode 100644
index 0000000..e6e1492
--- /dev/null
+++ b/frontend/src/components/ErrorMessage.tsx
@@ -0,0 +1,17 @@
+interface Props {
+ message: string;
+ onRetry?: () => void;
+}
+
+export function ErrorMessage({ message, onRetry }: Props) {
+ return (
+
+
{message}
+ {onRetry && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/FilterPanel.tsx b/frontend/src/components/FilterPanel.tsx
new file mode 100644
index 0000000..6fbd780
--- /dev/null
+++ b/frontend/src/components/FilterPanel.tsx
@@ -0,0 +1,149 @@
+import { useId, useState } from 'react';
+import { useQuery } from '@apollo/client/react';
+import { FACETS_QUERY, GENRES_QUERY } from '../graphql/operations';
+import type { FacetCount, TitleType } from '../graphql/types';
+import { decadeLabel, formatNumber, typeLabel } from '../lib/format';
+import { activeFilterCount, toFilters, type SearchState } from '../lib/searchState';
+
+interface Props {
+ state: SearchState;
+ onChange: (patch: Partial) => void;
+}
+
+const MIN_RATINGS = [0, 5, 6, 7, 8, 9];
+const TYPES: TitleType[] = ['MOVIE', 'SERIES'];
+
+function countMap(facets: FacetCount[] | undefined) {
+ return new Map((facets ?? []).map((f) => [f.value, f.count]));
+}
+
+function toggle(list: T[], item: T): T[] {
+ return list.includes(item) ? list.filter((x) => x !== item) : [...list, item];
+}
+
+export function FilterPanel({ state, onChange }: Props) {
+ const [open, setOpen] = useState(false);
+ const panelId = useId();
+ const ratingId = useId();
+ const count = activeFilterCount(state);
+
+ // Fasettene telles mot søketeksten og de ANDRE filtrene (serveren står for det).
+ const { data, previousData } = useQuery(FACETS_QUERY, {
+ variables: { query: state.q || null, filters: toFilters(state) },
+ });
+ // Forrige fasetter står igjen mens nye hentes, ellers blinker og hopper filterlista.
+ const facetData = data ?? previousData;
+ const { data: genreData } = useQuery(GENRES_QUERY);
+
+ const genreCounts = countMap(facetData?.facets.genres);
+ const decadeCounts = countMap(facetData?.facets.decades);
+ const typeCounts = countMap(facetData?.facets.types);
+
+ // Valgte verdier vises alltid, også om fasetten ikke lenger inneholder dem.
+ const genres = [
+ ...new Set([
+ ...(genreData?.genres ?? facetData?.facets.genres.map((g) => g.value) ?? []),
+ ...state.genres,
+ ]),
+ ].sort((a, b) => a.localeCompare(b, 'nb'));
+ const decades = [
+ ...new Set([
+ ...(facetData?.facets.decades.map((d) => Number(d.value)) ?? []),
+ ...state.decades,
+ ]),
+ ].sort((a, b) => b - a);
+
+ const suffix = (n: number | undefined) => (n === undefined ? '' : ` (${formatNumber(n)})`);
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
new file mode 100644
index 0000000..a9d4dfc
--- /dev/null
+++ b/frontend/src/components/Layout.tsx
@@ -0,0 +1,56 @@
+import { Suspense, useEffect, useRef, type MouseEvent } from 'react';
+import { NavLink, Outlet, ScrollRestoration, useLocation } from 'react-router';
+
+export function Layout() {
+ const main = useRef(null);
+ const { pathname } = useLocation();
+ const first = useRef(true);
+
+ // Etter navigasjon flyttes fokus til innholdet, ellers blir tastatur- og skjermleserbrukere stående i menyen.
+ useEffect(() => {
+ if (first.current) {
+ first.current = false;
+ return;
+ }
+ main.current?.focus({ preventScroll: true });
+ }, [pathname]);
+
+ const skip = (e: MouseEvent) => {
+ e.preventDefault();
+ main.current?.focus();
+ };
+
+ return (
+ <>
+
+ Hopp til innhold
+
+
+
+
Filmsøk
+
+
+
+
+ Laster …
}>
+
+
+
+
+
+ >
+ );
+}
diff --git a/frontend/src/components/ListToggleButton.tsx b/frontend/src/components/ListToggleButton.tsx
new file mode 100644
index 0000000..1a0037f
--- /dev/null
+++ b/frontend/src/components/ListToggleButton.tsx
@@ -0,0 +1,48 @@
+import { useState } from 'react';
+import { useMutation } from '@apollo/client/react';
+import { TOGGLE_LIST_MUTATION } from '../graphql/operations';
+
+interface Props {
+ titleId: string;
+ inMyList: boolean;
+}
+
+export function ListToggleButton({ titleId, inMyList }: Props) {
+ const [toggleList, { loading }] = useMutation(TOGGLE_LIST_MUTATION);
+ const [message, setMessage] = useState('');
+ const [failed, setFailed] = useState(false);
+
+ const onClick = async () => {
+ setFailed(false);
+ try {
+ // Tittelen normaliseres på id, så knappen oppdateres av cachen uten ekstra kode.
+ const { data } = await toggleList({ variables: { titleId } });
+ setMessage(data?.toggleList.inMyList ? 'Lagt til i min liste.' : 'Fjernet fra min liste.');
+ } catch {
+ setMessage('');
+ setFailed(true);
+ }
+ };
+
+ return (
+ <>
+
+
+ {message}
+
+ {failed && (
+
+ Kunne ikke oppdatere listen. Prøv igjen.
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/ResultSkeleton.tsx b/frontend/src/components/ResultSkeleton.tsx
new file mode 100644
index 0000000..394de86
--- /dev/null
+++ b/frontend/src/components/ResultSkeleton.tsx
@@ -0,0 +1,13 @@
+export function ResultSkeleton({ count = 6 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+ -
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/ReviewForm.test.tsx b/frontend/src/components/ReviewForm.test.tsx
new file mode 100644
index 0000000..c5737d1
--- /dev/null
+++ b/frontend/src/components/ReviewForm.test.tsx
@@ -0,0 +1,229 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+import { ADD_REVIEW_MUTATION } from '../graphql/operations';
+import { buildMocks, emptyLog, makeReview, renderApp } from '../test/utils';
+import { ReviewForm } from './ReviewForm';
+
+function setup({
+ delay,
+ failFirst = false,
+ onSubmitted = vi.fn(),
+}: { delay?: number; failFirst?: boolean; onSubmitted?: () => void } = {}) {
+ const log = emptyLog();
+ const mocks = buildMocks(
+ {
+ AddReview: (vars) => ({
+ addReview: makeReview(1, { ...(vars.input as object), isMine: true }),
+ }),
+ },
+ log,
+ failFirst
+ ? [
+ {
+ request: { query: ADD_REVIEW_MUTATION, variables: () => true },
+ maxUsageCount: 1,
+ error: new Error('Failed to fetch'),
+ },
+ ]
+ : [],
+ );
+ if (delay) for (const m of mocks) m.delay = delay;
+ renderApp(, { mocks });
+ const user = userEvent.setup();
+ return { log, user, onSubmitted };
+}
+
+const name = () => screen.getByLabelText('Navn');
+const text = () => screen.getByLabelText(/Anmeldelse \(valgfritt\)/);
+const stars = (n: number) =>
+ screen.getByRole('radio', { name: `${n} ${n === 1 ? 'stjerne' : 'stjerner'}` });
+const submit = () => screen.getByRole('button', { name: /Send anmeldelse|Sender/ });
+
+describe('ReviewForm: struktur', () => {
+ it('har stjerner som radioknapper i en fieldset med legend', () => {
+ setup();
+ const group = screen.getByRole('group', { name: 'Vurdering' });
+ expect(group.tagName).toBe('FIELDSET');
+ expect(screen.getAllByRole('radio')).toHaveLength(5);
+ });
+
+ it('viser tegnteller som teller mens man skriver', async () => {
+ const { user } = setup();
+ expect(screen.getByText('0 / 2000 tegn')).toBeInTheDocument();
+ await user.type(text(), 'Hei');
+ expect(screen.getByText('3 / 2000 tegn')).toBeInTheDocument();
+ expect(text()).toHaveAccessibleDescription(/3 \/ 2000 tegn/);
+ });
+
+ it('kan velges med tastaturet (piltaster i radiogruppen)', async () => {
+ const { user } = setup();
+ await user.click(stars(1));
+ await user.keyboard('{ArrowRight}{ArrowRight}');
+ expect(stars(3)).toBeChecked();
+ });
+});
+
+describe('ReviewForm: validering', () => {
+ it('avviser tomt skjema med feil på navn og stjerner, uten å sende', async () => {
+ const { user, log } = setup();
+ await user.click(submit());
+ expect(name()).toHaveAccessibleDescription('Skriv inn navnet ditt.');
+ expect(name()).toBeInvalid();
+ expect(screen.getByRole('group', { name: 'Vurdering' })).toHaveAccessibleDescription(
+ 'Velg et antall stjerner fra 1 til 5.',
+ );
+ expect(name()).toHaveFocus();
+ expect(log.AddReview).toHaveLength(0);
+ });
+
+ it('avviser navn som bare er mellomrom', async () => {
+ const { user, log } = setup();
+ await user.type(name(), ' ');
+ await user.click(stars(4));
+ await user.click(submit());
+ expect(name()).toHaveAccessibleDescription('Skriv inn navnet ditt.');
+ expect(log.AddReview).toHaveLength(0);
+ });
+
+ it('avviser navn over 50 tegn, men godtar nøyaktig 50', async () => {
+ const { user, log } = setup();
+ await user.click(name());
+ await user.paste('a'.repeat(51));
+ await user.click(stars(4));
+ await user.click(submit());
+ expect(name()).toHaveAccessibleDescription(/maks 50 tegn/);
+ expect(log.AddReview).toHaveLength(0);
+
+ await user.clear(name());
+ await user.paste('a'.repeat(50));
+ // Feilen forsvinner så fort feltet er rettet.
+ expect(name()).not.toBeInvalid();
+ await user.click(submit());
+ await waitFor(() => expect(log.AddReview).toHaveLength(1));
+ });
+
+ it('avviser innsending uten stjerner', async () => {
+ const { user, log } = setup();
+ await user.type(name(), 'Kari');
+ await user.click(submit());
+ expect(screen.getByText('Velg et antall stjerner fra 1 til 5.')).toBeInTheDocument();
+ expect(stars(1)).toHaveFocus();
+ expect(log.AddReview).toHaveLength(0);
+ await user.click(stars(2));
+ expect(screen.queryByText('Velg et antall stjerner fra 1 til 5.')).not.toBeInTheDocument();
+ });
+
+ it('avviser tekst over 2000 tegn med tydelig melding og rød teller', async () => {
+ const { user, log } = setup();
+ await user.type(name(), 'Kari');
+ await user.click(stars(5));
+ await user.click(text());
+ await user.paste('x'.repeat(2001));
+ expect(screen.getByText('2001 / 2000 tegn')).toBeInTheDocument();
+ await user.click(submit());
+ expect(text()).toHaveAccessibleDescription(/Teksten kan ha maks 2000 tegn. Fjern 1 tegn./);
+ expect(text()).toBeInvalid();
+ expect(text()).toHaveFocus();
+ expect(log.AddReview).toHaveLength(0);
+ });
+
+ it('tåler en svært lang tekst (100 000 tegn) uten å sende den', async () => {
+ const { user, log } = setup();
+ await user.type(name(), 'Kari');
+ await user.click(stars(5));
+ await user.click(text());
+ await user.paste('y'.repeat(100_000));
+ await user.click(submit());
+ expect(text()).toHaveAccessibleDescription(/Fjern 98000 tegn/);
+ expect(log.AddReview).toHaveLength(0);
+ });
+
+ it('godtar tom tekst (valgfri) og nøyaktig 2000 tegn', async () => {
+ const { user, log } = setup();
+ await user.type(name(), 'Kari');
+ await user.click(stars(3));
+ await user.click(submit());
+ await waitFor(() => expect(log.AddReview).toHaveLength(1));
+ expect(log.AddReview[0]).toEqual({
+ input: { titleId: 'tt0000001', author: 'Kari', rating: 3, text: '' },
+ });
+ await screen.findByText('Takk! Anmeldelsen din er lagt til.');
+
+ await user.click(stars(3));
+ await user.click(text());
+ await user.paste('z'.repeat(2000));
+ await user.click(submit());
+ await waitFor(() => expect(log.AddReview).toHaveLength(2));
+ });
+});
+
+describe('ReviewForm: innsending', () => {
+ it('sender trimmet innhold, viser suksessmelding i live-region, nullstiller og varsler forelderen', async () => {
+ const { user, log, onSubmitted } = setup();
+ await user.type(name(), ' Kari Nordmann ');
+ await user.click(stars(4));
+ await user.type(text(), ' Veldig bra! ');
+ await user.click(submit());
+
+ const status = await screen.findByText('Takk! Anmeldelsen din er lagt til.');
+ expect(status).toHaveAttribute('role', 'status');
+ expect(status).toHaveAttribute('aria-live', 'polite');
+ expect(log.AddReview[0]).toEqual({
+ input: { titleId: 'tt0000001', author: 'Kari Nordmann', rating: 4, text: 'Veldig bra!' },
+ });
+ expect(text()).toHaveValue('');
+ expect(stars(4)).not.toBeChecked();
+ // Navnet beholdes så brukeren slipper å skrive det på nytt.
+ expect(name()).toHaveValue(' Kari Nordmann ');
+ await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
+ expect(submit()).toBeEnabled();
+ });
+
+ it('deaktiverer knappen under sending og hindrer dobbeltsending', async () => {
+ const { user, log } = setup({ delay: 150 });
+ await user.type(name(), 'Kari');
+ await user.click(stars(4));
+ await user.click(submit());
+ const button = screen.getByRole('button', { name: 'Sender …' });
+ expect(button).toBeDisabled();
+ await user.click(button);
+ await screen.findByText('Takk! Anmeldelsen din er lagt til.');
+ expect(log.AddReview).toHaveLength(1);
+ expect(submit()).toBeEnabled();
+ });
+
+ it('viser feil ved nettverksfeil, beholder det brukeren skrev, og lar dem prøve igjen', async () => {
+ const { user, log } = setup({ failFirst: true });
+ await user.type(name(), 'Kari');
+ await user.click(stars(2));
+ await user.type(text(), 'Ikke helt min greie');
+ await user.click(submit());
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('Kunne ikke sende anmeldelsen');
+ expect(name()).toHaveValue('Kari');
+ expect(stars(2)).toBeChecked();
+ expect(text()).toHaveValue('Ikke helt min greie');
+ expect(submit()).toBeEnabled();
+ expect(screen.queryByText(/Takk!/)).not.toBeInTheDocument();
+
+ await user.click(submit());
+ await screen.findByText('Takk! Anmeldelsen din er lagt til.');
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(log.AddReview).toHaveLength(1);
+ });
+
+ it('sender spesialtegn, HTML og emoji uendret', async () => {
+ const { user, log } = setup();
+ const nasty = `
"sitat" & 'apostrof' æøå 🎬 \\ % _ ; --`;
+ await user.type(name(), 'Ola ');
+ await user.click(stars(5));
+ await user.click(text());
+ await user.paste(nasty);
+ await user.click(submit());
+ await screen.findByText('Takk! Anmeldelsen din er lagt til.');
+ expect(log.AddReview[0]).toEqual({
+ input: { titleId: 'tt0000001', author: 'Ola ', rating: 5, text: nasty },
+ });
+ });
+});
diff --git a/frontend/src/components/ReviewForm.tsx b/frontend/src/components/ReviewForm.tsx
new file mode 100644
index 0000000..5bcbadf
--- /dev/null
+++ b/frontend/src/components/ReviewForm.tsx
@@ -0,0 +1,184 @@
+import { useId, useRef, useState, type FormEvent } from 'react';
+import { useMutation } from '@apollo/client/react';
+import { ADD_REVIEW_MUTATION } from '../graphql/operations';
+import {
+ MAX_AUTHOR,
+ MAX_TEXT,
+ validateReview,
+ type ReviewDraft,
+ type ReviewErrors,
+} from '../lib/validateReview';
+
+interface Props {
+ titleId: string;
+ /** Kalles etter vellykket innsending, f.eks. for å hente lista på nytt. */
+ onSubmitted?: () => Promise | void;
+}
+
+const STAR_LABELS = ['1 stjerne', '2 stjerner', '3 stjerner', '4 stjerner', '5 stjerner'];
+
+export function ReviewForm({ titleId, onSubmitted }: Props) {
+ const uid = useId();
+ const ids = {
+ author: `${uid}-author`,
+ authorError: `${uid}-author-error`,
+ rating: `${uid}-rating-error`,
+ text: `${uid}-text`,
+ textError: `${uid}-text-error`,
+ counter: `${uid}-counter`,
+ };
+
+ const [draft, setDraft] = useState({ author: '', rating: null, text: '' });
+ const [errors, setErrors] = useState({});
+ const [submitted, setSubmitted] = useState(false);
+ const [success, setSuccess] = useState('');
+ const [serverError, setServerError] = useState('');
+ const authorRef = useRef(null);
+ const firstStarRef = useRef(null);
+ const textRef = useRef(null);
+
+ const [addReview, { loading }] = useMutation(ADD_REVIEW_MUTATION);
+
+ const change = (patch: Partial) => {
+ const next = { ...draft, ...patch };
+ setDraft(next);
+ setSuccess('');
+ // Etter første forsøk oppdateres feilene mens brukeren retter dem.
+ if (submitted) setErrors(validateReview(next));
+ };
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (loading) return;
+ setSuccess('');
+ setServerError('');
+ setSubmitted(true);
+
+ const found = validateReview(draft);
+ setErrors(found);
+ if (found.author) return authorRef.current?.focus();
+ if (found.rating) return firstStarRef.current?.focus();
+ if (found.text) return textRef.current?.focus();
+
+ try {
+ await addReview({
+ variables: {
+ input: {
+ titleId,
+ author: draft.author.trim(),
+ rating: draft.rating!,
+ text: draft.text.trim(),
+ },
+ },
+ });
+ } catch {
+ setServerError('Kunne ikke sende anmeldelsen. Sjekk nettverket og prøv igjen.');
+ return;
+ }
+ setDraft({ author: draft.author, rating: null, text: '' });
+ setErrors({});
+ setSubmitted(false);
+ setSuccess('Takk! Anmeldelsen din er lagt til.');
+ try {
+ await onSubmitted?.();
+ } catch {
+ // Anmeldelsen er lagret; en mislykket oppdatering av lista er ikke kritisk.
+ }
+ };
+
+ const textLength = draft.text.length;
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/ReviewList.tsx b/frontend/src/components/ReviewList.tsx
new file mode 100644
index 0000000..177d16a
--- /dev/null
+++ b/frontend/src/components/ReviewList.tsx
@@ -0,0 +1,28 @@
+import { formatDate } from '../lib/format';
+import type { Review } from '../graphql/types';
+import { Stars } from './Stars';
+
+export function ReviewList({ reviews }: { reviews: Review[] }) {
+ if (reviews.length === 0) {
+ return Ingen anmeldelser ennå. Bli den første!
;
+ }
+ return (
+
+ {reviews.map((r) => (
+ -
+
+
+ {r.author}
+ {r.isMine && Din anmeldelse}
+
+
+
+ {r.text &&
{r.text}
}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/SearchBox.test.tsx b/frontend/src/components/SearchBox.test.tsx
new file mode 100644
index 0000000..8ea87b2
--- /dev/null
+++ b/frontend/src/components/SearchBox.test.tsx
@@ -0,0 +1,133 @@
+import { useState } from 'react';
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SearchBox } from './SearchBox';
+
+function setup(initial = '') {
+ const onCommit = vi.fn();
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) });
+ // Speiler hvordan siden bruker komponenten: verdien kommer fra (URL-)state.
+ function Host() {
+ const [value, setValue] = useState(initial);
+ return (
+ {
+ onCommit(q);
+ setValue(q);
+ }}
+ />
+ );
+ }
+ render();
+ return { user, onCommit, input: screen.getByLabelText('Søk etter tittel') };
+}
+
+describe('SearchBox', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ // Testing Library oppdager falske timere bare via et globalt `jest`-objekt; uten denne
+ // henger user-event fordi RTL venter på en timer som aldri tikker.
+ vi.stubGlobal('jest', { advanceTimersByTime: vi.advanceTimersByTime.bind(vi) });
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('har en synlig label knyttet til feltet', () => {
+ const { input } = setup();
+ expect(input).toHaveAccessibleName('Søk etter tittel');
+ });
+
+ it('venter 300 ms etter siste tastetrykk før den melder fra', async () => {
+ const { user, onCommit, input } = setup();
+ await user.type(input, 'matrix');
+ expect(onCommit).not.toHaveBeenCalled();
+ act(() => vi.advanceTimersByTime(299));
+ expect(onCommit).not.toHaveBeenCalled();
+ act(() => vi.advanceTimersByTime(1));
+ expect(onCommit).toHaveBeenCalledTimes(1);
+ expect(onCommit).toHaveBeenCalledWith('matrix');
+ });
+
+ it('slår sammen raske tastetrykk til ett kall', async () => {
+ const { user, onCommit, input } = setup();
+ await user.type(input, 'ab');
+ act(() => vi.advanceTimersByTime(200));
+ await user.type(input, 'c');
+ act(() => vi.advanceTimersByTime(299));
+ expect(onCommit).not.toHaveBeenCalled();
+ act(() => vi.advanceTimersByTime(1));
+ expect(onCommit).toHaveBeenCalledExactlyOnceWith('abc');
+ });
+
+ it('trimmer teksten', async () => {
+ const { user, onCommit, input } = setup();
+ await user.type(input, ' alien ');
+ act(() => vi.advanceTimersByTime(300));
+ expect(onCommit).toHaveBeenCalledExactlyOnceWith('alien');
+ });
+
+ it('gjør ingenting når bare mellomrom legges til eller fjernes', async () => {
+ const { user, onCommit, input } = setup();
+ await user.type(input, 'alien');
+ act(() => vi.advanceTimersByTime(300));
+ onCommit.mockClear();
+ await user.type(input, ' ');
+ act(() => vi.advanceTimersByTime(300));
+ await user.type(input, '{Backspace}{Backspace}{Backspace}');
+ act(() => vi.advanceTimersByTime(300));
+ expect(onCommit).not.toHaveBeenCalled();
+ });
+
+ it('sender ikke noe for et felt som bare inneholder mellomrom', async () => {
+ const { user, onCommit, input } = setup();
+ await user.type(input, ' ');
+ act(() => vi.advanceTimersByTime(1000));
+ expect(onCommit).not.toHaveBeenCalled();
+ });
+
+ it('sender ikke på nytt når teksten settes tilbake til det som allerede gjelder', async () => {
+ const { user, onCommit, input } = setup();
+ await user.type(input, 'heat');
+ act(() => vi.advanceTimersByTime(300));
+ onCommit.mockClear();
+ await user.type(input, 'x{Backspace}');
+ act(() => vi.advanceTimersByTime(300));
+ expect(onCommit).not.toHaveBeenCalled();
+ });
+
+ it('tøm-knappen tømmer feltet, melder fra med en gang og flytter fokus til feltet', async () => {
+ const { user, onCommit, input } = setup('alien');
+ const clear = screen.getByRole('button', { name: 'Tøm søkefeltet' });
+ await user.click(clear);
+ expect(input).toHaveValue('');
+ expect(input).toHaveFocus();
+ expect(onCommit).toHaveBeenCalledExactlyOnceWith('');
+ expect(screen.queryByRole('button', { name: 'Tøm søkefeltet' })).not.toBeInTheDocument();
+ });
+
+ it('viser ikke tøm-knapp når feltet er tomt', () => {
+ setup();
+ expect(screen.queryByRole('button', { name: 'Tøm søkefeltet' })).not.toBeInTheDocument();
+ });
+
+ it('Enter sender med en gang uten å vente på debounce', async () => {
+ const { user, onCommit, input } = setup();
+ await user.type(input, 'fargo{Enter}');
+ expect(onCommit).toHaveBeenCalledExactlyOnceWith('fargo');
+ act(() => vi.advanceTimersByTime(1000));
+ expect(onCommit).toHaveBeenCalledTimes(1);
+ });
+
+ it('tåler spesialtegn og veldig lange strenger uten å krasje', async () => {
+ const { user, onCommit, input } = setup();
+ const weird = ` "quoted" \\ %_`;
+ const res = await env.gql(ADD, { input: { ...valid, text } }, USER_A);
+ expect(res.data!.addReview.text).toBe(text);
+ const { rows } = await env.pool.query('SELECT count(*)::int AS n FROM reviews');
+ expect(rows[0].n).toBeGreaterThan(0);
+ });
+
+ it.each([
+ ['tom forfatter', { author: '' }],
+ ['forfatter med bare mellomrom', { author: ' ' }],
+ ['forfatter på 51 tegn', { author: 'a'.repeat(51) }],
+ ['rating 0', { rating: 0 }],
+ ['rating 6', { rating: 6 }],
+ ['negativ rating', { rating: -1 }],
+ ['tekst på 2001 tegn', { text: 'x'.repeat(2001) }],
+ ['NUL-tegn i tekst', { text: 'a\u0000b' }],
+ ['NUL-tegn i forfatter', { author: 'a\u0000b' }],
+ ])('avviser %s med BAD_USER_INPUT', async (_navn, patch) => {
+ const before = (await env.pool.query('SELECT count(*)::int AS n FROM reviews')).rows[0].n;
+ const res = await env.gql(ADD, { input: { ...valid, ...patch } }, USER_A);
+ expect(code(res)).toBe('BAD_USER_INPUT');
+ const after = (await env.pool.query('SELECT count(*)::int AS n FROM reviews')).rows[0].n;
+ expect(after).toBe(before);
+ });
+
+ it('avviser ikke-heltall og manglende felt (skjemavalidering)', async () => {
+ expect((await env.gql(ADD, { input: { ...valid, rating: 2.5 } }, USER_A)).errors).toBeDefined();
+ expect((await env.gql(ADD, { input: { titleId: 'tt0000001' } }, USER_A)).errors).toBeDefined();
+ expect((await env.gql(ADD, { input: { ...valid, rating: '5' } }, USER_A)).errors).toBeDefined();
+ });
+
+ it('gir NOT_FOUND for ukjent tittel', async () => {
+ expect(code(await env.gql(ADD, { input: { ...valid, titleId: 'tt9999999' } }, USER_A))).toBe(
+ 'NOT_FOUND',
+ );
+ expect(code(await env.gql(ADD, { input: { ...valid, titleId: "x' OR 1=1" } }, USER_A))).toBe(
+ 'NOT_FOUND',
+ );
+ });
+
+ it('gir UNAUTHENTICATED uten x-user-id eller med ugyldig UUID', async () => {
+ expect(code(await env.gql(ADD, { input: valid }))).toBe('UNAUTHENTICATED');
+ expect(code(await env.gql(ADD, { input: valid }, 'ikke-en-uuid'))).toBe('UNAUTHENTICATED');
+ expect(code(await env.gql(ADD, { input: valid }, "'; DROP TABLE reviews;--"))).toBe(
+ 'UNAUTHENTICATED',
+ );
+ });
+});
+
+describe('toggleList og myList', () => {
+ it('myList er tom uten x-user-id og for ny bruker', async () => {
+ for (const user of [null, USER_A, 'ugyldig']) {
+ const res = await env.gql(MY_LIST, {}, user);
+ expect(res.errors).toBeUndefined();
+ expect(res.data!.myList).toEqual({
+ totalCount: 0,
+ pageInfo: { hasNextPage: false, endCursor: null },
+ edges: [],
+ });
+ }
+ });
+
+ it('slår av og på', async () => {
+ const on = await env.gql(TOGGLE, { id: 'tt0000005' }, USER_A);
+ expect(on.data!.toggleList).toEqual({
+ id: 'tt0000005',
+ inMyList: true,
+ primaryTitle: 'Amélie',
+ });
+ expect(
+ (await env.gql(MY_LIST, {}, USER_A)).data!.myList.edges.map(
+ (e: { node: { id: string } }) => e.node.id,
+ ),
+ ).toEqual(['tt0000005']);
+ const detail = await env.gql('{ title(id: "tt0000005") { inMyList } }', undefined, USER_A);
+ expect(detail.data!.title.inMyList).toBe(true);
+
+ const off = await env.gql(TOGGLE, { id: 'tt0000005' }, USER_A);
+ expect(off.data!.toggleList.inMyList).toBe(false);
+ expect((await env.gql(MY_LIST, {}, USER_A)).data!.myList.totalCount).toBe(0);
+ const on2 = await env.gql(TOGGLE, { id: 'tt0000005' }, USER_A);
+ expect(on2.data!.toggleList.inMyList).toBe(true);
+ await env.gql(TOGGLE, { id: 'tt0000005' }, USER_A);
+ });
+
+ it('lister er separate per bruker', async () => {
+ await env.gql(TOGGLE, { id: 'tt0000006' }, USER_A);
+ const b = await env.gql(MY_LIST, {}, USER_B);
+ expect(b.data!.myList.totalCount).toBe(0);
+ const search = await env.gql(
+ '{ search(query: "Café") { edges { node { id inMyList } } } }',
+ undefined,
+ USER_B,
+ );
+ expect(search.data!.search.edges[0].node.inMyList).toBe(false);
+ const searchA = await env.gql(
+ '{ search(query: "Café") { edges { node { id inMyList } } } }',
+ undefined,
+ USER_A,
+ );
+ expect(searchA.data!.search.edges[0].node.inMyList).toBe(true);
+ await env.gql(TOGGLE, { id: 'tt0000006' }, USER_A);
+ });
+
+ it('myList: sist lagt til først, med paginering', async () => {
+ const order = ['tt0000001', 'tt0000002', 'tt0000003', 'tt0000004', 'tt0000005'];
+ for (const id of order) await env.gql(TOGGLE, { id }, USER_A);
+ const seen: string[] = [];
+ let after: string | null = null;
+ let pages = 0;
+ do {
+ const res: GqlResult = await env.gql(MY_LIST, { first: 2, after }, USER_A);
+ expect(res.errors).toBeUndefined();
+ const list: Record = res.data!.myList;
+ expect(list.totalCount).toBe(5);
+ for (const e of list.edges) {
+ seen.push(e.node.id);
+ expect(e.node.inMyList).toBe(true);
+ }
+ after = list.pageInfo.hasNextPage ? list.pageInfo.endCursor : null;
+ pages++;
+ } while (after && pages < 10);
+ expect(seen).toEqual([...order].reverse());
+ expect(pages).toBe(3);
+ });
+
+ it('myList validerer first og cursor', async () => {
+ expect(code(await env.gql(MY_LIST, { first: 0 }, USER_A))).toBe('BAD_USER_INPUT');
+ expect(code(await env.gql(MY_LIST, { first: 51 }, USER_A))).toBe('BAD_USER_INPUT');
+ expect(code(await env.gql(MY_LIST, { after: 'tull' }, USER_A))).toBe('BAD_USER_INPUT');
+ const bad = Buffer.from('{"s":"myList","v":["ikke-dato","x"]}').toString('base64url');
+ expect(code(await env.gql(MY_LIST, { after: bad }, USER_A))).toBe('BAD_USER_INPUT');
+ });
+
+ it('krever x-user-id og eksisterende tittel', async () => {
+ expect(code(await env.gql(TOGGLE, { id: 'tt0000001' }))).toBe('UNAUTHENTICATED');
+ expect(code(await env.gql(TOGGLE, { id: 'tt0000001' }, 'abc'))).toBe('UNAUTHENTICATED');
+ expect(code(await env.gql(TOGGLE, { id: 'tt9999999' }, USER_A))).toBe('NOT_FOUND');
+ });
+
+ it('to samtidige toggles gir ikke intern feil', async () => {
+ const results = await Promise.all([
+ env.gql(TOGGLE, { id: 'tt0000020' }, USER_B),
+ env.gql(TOGGLE, { id: 'tt0000020' }, USER_B),
+ ]);
+ for (const r of results) expect(r.errors).toBeUndefined();
+ });
+});
diff --git a/backend/test/pagination.test.ts b/backend/test/pagination.test.ts
new file mode 100644
index 0000000..a7b106a
--- /dev/null
+++ b/backend/test/pagination.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { TITLE_COUNT } from './fixtures.js';
+import { setupApi } from './helpers.js';
+import { collectIds, SEARCH } from './queries.js';
+
+const env = setupApi();
+
+/** Fasit: rekkefølgen Postgres selv gir med samme sorteringsnøkler, uavhengig av cursor-koden. */
+const REFERENCE: Record = {
+ 'RATING DESC': 'COALESCE(average_rating,-1) DESC, num_votes DESC, id DESC',
+ 'RATING ASC': 'COALESCE(average_rating,-1) ASC, num_votes ASC, id ASC',
+ 'YEAR DESC': 'COALESCE(start_year,0) DESC, num_votes DESC, id DESC',
+ 'YEAR ASC': 'COALESCE(start_year,0) ASC, num_votes ASC, id ASC',
+ 'TITLE ASC': 'lower(primary_title) ASC, id ASC',
+ 'TITLE DESC': 'lower(primary_title) DESC, id DESC',
+ 'RELEVANCE DESC': 'num_votes DESC, id DESC',
+ 'RELEVANCE ASC': 'num_votes ASC, id ASC',
+};
+
+describe('paginering og sortering', () => {
+ it.each(Object.keys(REFERENCE))(
+ '%s: alle sider gir hele settet i riktig rekkefølge',
+ async (key) => {
+ const [field, direction] = key.split(' ') as [string, string];
+ const { rows } = await env.pool.query<{ id: string }>(
+ `SELECT id FROM titles ORDER BY ${REFERENCE[key]}`,
+ );
+ const expected = rows.map((r) => r.id);
+ for (const pageSize of [1, 7, 50]) {
+ const got = await collectIds(env, { sort: { field, direction } }, pageSize);
+ expect(got.ids, `pageSize ${pageSize}`).toEqual(expected);
+ expect(new Set(got.ids).size).toBe(TITLE_COUNT);
+ expect(got.total).toBe(TITLE_COUNT);
+ }
+ },
+ );
+
+ it('standardretning per felt følger kontrakten', async () => {
+ const dir = async (field: string, direction?: string) =>
+ (await collectIds(env, { sort: { field, direction } }, 50)).ids;
+ expect(await dir('RATING')).toEqual(await dir('RATING', 'DESC'));
+ expect(await dir('YEAR')).toEqual(await dir('YEAR', 'DESC'));
+ expect(await dir('TITLE')).toEqual(await dir('TITLE', 'ASC'));
+ expect(await dir('RELEVANCE')).toEqual(await dir('RELEVANCE', 'DESC'));
+ const noSort = (await collectIds(env, {}, 50)).ids;
+ expect(noSort).toEqual(await dir('RELEVANCE', 'DESC'));
+ });
+
+ it('titler uten rating/år er med i sorteringene (ingen forsvunne rader)', async () => {
+ for (const field of ['RATING', 'YEAR']) {
+ for (const direction of ['ASC', 'DESC']) {
+ const { ids } = await collectIds(env, { sort: { field, direction } }, 4);
+ expect(ids).toContain(field === 'RATING' ? 'tt0000019' : 'tt0000018');
+ }
+ }
+ });
+
+ it.each(['RELEVANCE', 'RATING', 'YEAR', 'TITLE'])(
+ 'søk + filter + %s gir stabile sider uten duplikater',
+ async (field) => {
+ for (const direction of ['ASC', 'DESC']) {
+ const vars = {
+ query: 'a',
+ filters: { types: ['MOVIE', 'SERIES'], minRating: 3 },
+ sort: { field, direction },
+ };
+ const single = await collectIds(env, vars, 50);
+ const paged = await collectIds(env, vars, 5);
+ expect(paged.ids).toEqual(single.ids);
+ expect(new Set(paged.ids).size).toBe(paged.ids.length);
+ expect(paged.total).toBe(single.ids.length);
+ }
+ },
+ );
+
+ it('relevans med søketekst er monotont synkende i likhet, deretter stemmer', async () => {
+ const { rows } = await env.pool.query<{ id: string; rel: number; num_votes: number }>(
+ `SELECT id, num_votes,
+ GREATEST(similarity(lower(primary_title), 'dark'), similarity(lower(original_title), 'dark'))::float8 AS rel
+ FROM titles WHERE lower(primary_title) LIKE '%dark%' OR lower(original_title) LIKE '%dark%'
+ ORDER BY rel DESC, num_votes DESC, id DESC`,
+ );
+ const paged = await collectIds(env, { query: 'dark', sort: { field: 'RELEVANCE' } }, 2);
+ expect(paged.ids).toEqual(rows.map((r) => r.id));
+ const asc = await collectIds(
+ env,
+ { query: 'dark', sort: { field: 'RELEVANCE', direction: 'ASC' } },
+ 2,
+ );
+ expect(asc.ids).toEqual([...rows].reverse().map((r) => r.id));
+ });
+
+ it('siste side har hasNextPage=false og endCursor for siste rad', async () => {
+ const r = await env.gql(SEARCH, { first: TITLE_COUNT });
+ expect(r.data!.search.pageInfo.hasNextPage).toBe(false);
+ const edges = r.data!.search.edges;
+ expect(r.data!.search.pageInfo.endCursor).toBe(edges[edges.length - 1].cursor);
+ const beyond = await env.gql(SEARCH, { first: 5, after: edges[edges.length - 1].cursor });
+ expect(beyond.data!.search.edges).toEqual([]);
+ expect(beyond.data!.search.pageInfo).toEqual({ hasNextPage: false, endCursor: null });
+ });
+
+ it('exakt fullt sett (first == antall) gir hasNextPage=false', async () => {
+ const r = await env.gql(SEARCH, { first: 50, filters: { types: ['SERIES'] } });
+ expect(r.data!.search.pageInfo.hasNextPage).toBe(false);
+ });
+});
diff --git a/backend/test/queries.ts b/backend/test/queries.ts
new file mode 100644
index 0000000..991b050
--- /dev/null
+++ b/backend/test/queries.ts
@@ -0,0 +1,62 @@
+/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */
+import type { GqlResult, TestEnv } from './helpers.js';
+
+export const SEARCH = /* GraphQL */ `
+ query Search(
+ $query: String
+ $filters: SearchFilters
+ $sort: SortInput
+ $first: Int
+ $after: String
+ ) {
+ search(query: $query, filters: $filters, sort: $sort, first: $first, after: $after) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ edges {
+ cursor
+ node {
+ id
+ primaryTitle
+ originalTitle
+ type
+ startYear
+ endYear
+ genres
+ averageRating
+ numVotes
+ }
+ }
+ }
+ }
+`;
+
+export interface SearchVars {
+ query?: string | null;
+ filters?: Record | null;
+ sort?: { field: string; direction?: string } | null;
+ first?: number;
+ after?: string | null;
+}
+
+/** Henter alle sider til pageInfo.hasNextPage er false, og returnerer id-ene i rekkefølge. */
+export async function collectIds(env: TestEnv, vars: SearchVars, pageSize = 50) {
+ const ids: string[] = [];
+ let after: string | null = null;
+ let pages = 0;
+ let total = -1;
+ for (;;) {
+ const res: GqlResult = await env.gql(SEARCH, { ...vars, first: pageSize, after });
+ if (res.errors) throw new Error(JSON.stringify(res.errors));
+ const conn: Record = res.data!.search;
+ total = conn.totalCount;
+ ids.push(...conn.edges.map((e: { node: { id: string } }) => e.node.id));
+ pages++;
+ if (!conn.pageInfo.hasNextPage) break;
+ after = conn.pageInfo.endCursor;
+ if (pages > 200) throw new Error('For mange sider – mulig evig løkke');
+ }
+ return { ids, pages, total };
+}
diff --git a/backend/test/search.test.ts b/backend/test/search.test.ts
new file mode 100644
index 0000000..17e690c
--- /dev/null
+++ b/backend/test/search.test.ts
@@ -0,0 +1,241 @@
+import { describe, expect, it } from 'vitest';
+import { TITLES, TITLE_COUNT } from './fixtures.js';
+import { setupApi } from './helpers.js';
+import { SEARCH } from './queries.js';
+
+const env = setupApi();
+
+const idsMatching = (pred: (t: (typeof TITLES)[number]) => boolean) =>
+ TITLES.filter(pred)
+ .map((t) => t.id)
+ .sort();
+
+const search = async (vars: Record) => {
+ const res = await env.gql(SEARCH, { first: 50, ...vars });
+ const conn = res.data?.search;
+ return {
+ res,
+ ids: (conn?.edges ?? []).map((e: { node: { id: string } }) => e.node.id) as string[],
+ total: conn?.totalCount as number | undefined,
+ titles: (conn?.edges ?? []).map(
+ (e: { node: { primaryTitle: string } }) => e.node.primaryTitle,
+ ) as string[],
+ };
+};
+
+describe('search: tekst', () => {
+ it('finner delstrenger case-insensitivt i primær- og originaltittel', async () => {
+ const expected = idsMatching((t) =>
+ `${t.primary} ${t.original ?? ''}`.toLowerCase().includes('dark'),
+ );
+ for (const q of ['dark', 'DARK', 'DaRk']) {
+ const r = await search({ query: q });
+ expect([...r.ids].sort()).toEqual(expected);
+ expect(r.total).toBe(expected.length);
+ }
+ expect(expected.length).toBeGreaterThanOrEqual(5);
+ });
+
+ it('rangerer eksakt treff først ved relevans', async () => {
+ const r = await search({ query: 'dark' });
+ expect(r.titles[0]).toBe('Dark');
+ });
+
+ it('søker i originaltittel', async () => {
+ expect((await search({ query: 'fabuleux' })).titles).toEqual(['Amélie']);
+ expect((await search({ query: 'Ça' })).titles).toEqual(['It']);
+ });
+
+ it.each([null, '', ' ', '\t\n'])('tomt søk %j gir alle titler', async (q) => {
+ const r = await search({ query: q, first: 50 });
+ expect(r.total).toBe(TITLE_COUNT);
+ expect(r.ids).toHaveLength(TITLE_COUNT);
+ });
+
+ it('gir tomt resultat uten feil når ingenting treffer', async () => {
+ const res = await env.gql(SEARCH, { query: 'zzzqqqxxx', first: 10 });
+ expect(res.errors).toBeUndefined();
+ expect(res.data!.search).toEqual({
+ totalCount: 0,
+ pageInfo: { hasNextPage: false, endCursor: null },
+ edges: [],
+ });
+ });
+
+ it.each([
+ ['%', ['100% Pure']],
+ ['_', ['Under_score Story']],
+ ['0% P', ['100% Pure']],
+ ['er_sc', ['Under_score Story']],
+ ["'", ['Amélie', "Don't Look Up", "O'Brien's Odyssey"]],
+ ['"', ['Say "Cheese"']],
+ ['\\', ['Back\\Slash']],
+ ['🎬', ['Emoji 🎬 Night']],
+ ['amélie', ['Amélie']],
+ ['CAFÉ', ['Café Society']],
+ ["Don't", ["Don't Look Up"]],
+ ])('behandler spesialtegn %j som vanlig tekst', async (q, expected) => {
+ const r = await search({ query: q });
+ expect(r.res.errors).toBeUndefined();
+ expect([...r.titles].sort()).toEqual([...expected].sort());
+ });
+
+ it('er immun mot SQL-injeksjon i søketeksten', async () => {
+ for (const q of ["'; DROP TABLE titles; --", "' OR '1'='1", '") OR 1=1 --']) {
+ const r = await search({ query: q });
+ expect(r.res.errors).toBeUndefined();
+ expect(r.total).toBe(0);
+ }
+ expect((await search({})).total).toBe(TITLE_COUNT);
+ });
+
+ it('avviser query over 200 tegn, godtar nøyaktig 200', async () => {
+ for (const len of [201, 10_000]) {
+ const res = await env.gql(SEARCH, { query: 'a'.repeat(len), first: 5 });
+ expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ expect(res.data).toBeNull();
+ }
+ const ok = await env.gql(SEARCH, { query: 'a'.repeat(200), first: 5 });
+ expect(ok.errors).toBeUndefined();
+ });
+
+ it('avviser NUL-tegn i søketeksten som brukerfeil', async () => {
+ const res = await env.gql(SEARCH, { query: 'a\u0000b', first: 5 });
+ expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ });
+});
+
+describe('search: filtre', () => {
+ it('genres krever ALLE valgte sjangre', async () => {
+ const drama = await search({ filters: { genres: ['Drama'] } });
+ expect([...drama.ids].sort()).toEqual(idsMatching((t) => t.genres.includes('Drama')));
+ const both = await search({ filters: { genres: ['Crime', 'Drama'] } });
+ expect([...both.ids].sort()).toEqual(
+ idsMatching((t) => t.genres.includes('Crime') && t.genres.includes('Drama')),
+ );
+ expect(both.total).toBe(3);
+ });
+
+ it('ukjent sjanger gir 0 treff', async () => {
+ expect((await search({ filters: { genres: ['Finnes Ikke'] } })).total).toBe(0);
+ });
+
+ it('decades: flere tiår er ELLER', async () => {
+ const one = await search({ filters: { decades: [1990] } });
+ expect([...one.ids].sort()).toEqual(
+ idsMatching((t) => t.year !== null && t.year >= 1990 && t.year < 2000),
+ );
+ const two = await search({ filters: { decades: [1990, 2010] } });
+ expect([...two.ids].sort()).toEqual(
+ idsMatching((t) => t.year !== null && [1990, 2010].includes(Math.floor(t.year / 10) * 10)),
+ );
+ });
+
+ it('types: MOVIE, SERIES og begge', async () => {
+ const series = await search({ filters: { types: ['SERIES'] } });
+ expect([...series.ids].sort()).toEqual(idsMatching((t) => t.type === 'series'));
+ expect((await search({ filters: { types: ['MOVIE', 'SERIES'] } })).total).toBe(TITLE_COUNT);
+ expect((await search({ filters: { types: ['MOVIE'] } })).total).toBe(
+ TITLES.filter((t) => t.type === 'movie').length,
+ );
+ });
+
+ it('minRating er >= og utelater titler uten rating', async () => {
+ const r = await search({ filters: { minRating: 8 } });
+ expect([...r.ids].sort()).toEqual(idsMatching((t) => t.rating !== null && t.rating >= 8));
+ expect(r.ids).not.toContain('tt0000019');
+ // 0 betyr ingen grense, også for titler uten rating
+ expect((await search({ filters: { minRating: 0 } })).total).toBe(TITLE_COUNT);
+ });
+
+ it('kombinerer søketekst og alle filtre', async () => {
+ const filters = { genres: ['Drama'], decades: [2000, 2010], types: ['MOVIE'], minRating: 7 };
+ const r = await search({ query: 'dark', filters });
+ expect(r.titles.sort()).toEqual(['Dark Waters', 'The Dark Knight']);
+ const none = await search({ query: 'dark', filters: { ...filters, minRating: 9.5 } });
+ expect(none.total).toBe(0);
+ });
+
+ it.each([
+ ['tiår som ikke er helt', { decades: [1995] }],
+ ['tiår utenfor rimelig område', { decades: [3000] }],
+ ['minRating over 10', { minRating: 10.5 }],
+ ['negativ minRating', { minRating: -1 }],
+ ['tom sjangerstreng', { genres: [''] }],
+ ])('avviser ugyldig filter: %s', async (_navn, filters) => {
+ const res = await env.gql(SEARCH, { filters, first: 5 });
+ expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ });
+
+ it('tomme filterlister betyr «ingen filter»', async () => {
+ const r = await search({ filters: { genres: [], decades: [], types: [] } });
+ expect(r.total).toBe(TITLE_COUNT);
+ });
+});
+
+describe('search: felter og feil', () => {
+ it('returnerer alle kontraktsfeltene', async () => {
+ const r = await env.gql(SEARCH, { query: 'Breaking Bad', first: 1 });
+ expect(r.data!.search.edges[0].node).toEqual({
+ id: 'tt0000015',
+ primaryTitle: 'Breaking Bad',
+ originalTitle: 'Breaking Bad',
+ type: 'SERIES',
+ startYear: 2008,
+ endYear: 2013,
+ genres: ['Crime', 'Drama', 'Thriller'],
+ averageRating: 9.5,
+ numVotes: 2200000,
+ });
+ });
+
+ it('null i valgfrie felt (år, rating)', async () => {
+ const r = await search({ query: 'Unknown Year' });
+ expect(r.ids).toEqual(['tt0000018']);
+ const res = await env.gql(SEARCH, { query: 'Unrated Film', first: 1 });
+ expect(res.data!.search.edges[0].node.averageRating).toBeNull();
+ });
+
+ it.each([0, 51, -3, 1000])('avviser first=%s', async (first) => {
+ const res = await env.gql(SEARCH, { first });
+ expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ });
+
+ it('bruker first=20 som standard, og godtar grensene 1 og 50', async () => {
+ const def = await env.gql(`{ search { edges { cursor } pageInfo { hasNextPage } } }`);
+ expect(def.data!.search.edges).toHaveLength(20);
+ expect(def.data!.search.pageInfo.hasNextPage).toBe(true);
+ expect((await search({ first: 1 })).ids).toHaveLength(1);
+ expect((await search({ first: 50 })).ids).toHaveLength(TITLE_COUNT);
+ });
+
+ it.each(['ikke-en-cursor', '%%%', 'e30', Buffer.from('{"s":"x","v":[]}').toString('base64url')])(
+ 'avviser ugyldig cursor %j',
+ async (after) => {
+ const res = await env.gql(SEARCH, { first: 5, after });
+ expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ },
+ );
+
+ it('avviser cursor brukt med en annen sortering eller søketekst', async () => {
+ const first = await env.gql(SEARCH, { first: 2, sort: { field: 'RATING' } });
+ const cursor = first.data!.search.pageInfo.endCursor as string;
+ for (const other of [
+ { sort: { field: 'YEAR' } },
+ { sort: { field: 'RATING', direction: 'ASC' } },
+ { sort: { field: 'RATING' }, query: 'dark' },
+ ]) {
+ const res = await env.gql(SEARCH, { first: 2, after: cursor, ...other });
+ expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ }
+ });
+
+ it('gir syntaks- og valideringsfeil uten å krasje', async () => {
+ const syntax = await env.gql('{ search( ');
+ expect(syntax.errors).toBeDefined();
+ const unknown = await env.gql('{ finnesIkke }');
+ expect(unknown.errors).toBeDefined();
+ const badEnum = await env.gql(SEARCH, { sort: { field: 'PRICE' } });
+ expect(badEnum.errors).toBeDefined();
+ });
+});
diff --git a/backend/test/server.test.ts b/backend/test/server.test.ts
new file mode 100644
index 0000000..45aa49e
--- /dev/null
+++ b/backend/test/server.test.ts
@@ -0,0 +1,84 @@
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { createApp } from '../src/app.js';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+import { resetData } from './fixtures.js';
+
+const post = (
+ yoga: ReturnType,
+ query: string,
+ headers: Record = {},
+) =>
+ yoga.fetch('http://localhost/graphql', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...headers },
+ body: JSON.stringify({ query }),
+ });
+
+let pool: Pool;
+beforeAll(async () => {
+ pool = createPool(config.testDatabaseUrl);
+ await resetData(pool);
+});
+afterAll(async () => {
+ await pool.end();
+});
+
+describe('servergrenser', () => {
+ it('slår av introspeksjon i produksjon, men ikke i utvikling', async () => {
+ const dev = createApp({ pool, production: false });
+ const prod = createApp({ pool, production: true });
+ const q = '{ __schema { queryType { name } } }';
+ expect((await (await post(dev, q)).json()).data.__schema.queryType.name).toBe('Query');
+ const res = await (await post(prod, q)).json();
+ expect(res.data).toBeUndefined();
+ expect(res.errors[0].message).toMatch(/introspection/i);
+ // vanlige spørringer virker fortsatt
+ expect((await (await post(prod, '{ genres }')).json()).data.genres.length).toBeGreaterThan(0);
+ });
+
+ it('maskerer uventede feil uten å lekke detaljer', async () => {
+ const broken = createPool(config.testDatabaseUrl);
+ await broken.end(); // alle spørringer feiler nå
+ const yoga = createApp({ pool: broken, production: false });
+ const res = await (await post(yoga, '{ genres }')).json();
+ expect(res.errors).toHaveLength(1);
+ expect(res.errors[0].extensions.code).toBe('INTERNAL_SERVER_ERROR');
+ expect(JSON.stringify(res)).not.toMatch(/pool|postgres|ECONN|SELECT|stack/i);
+ });
+
+ it('sender CORS-headere bare når corsOrigin er satt', async () => {
+ const withCors = createApp({ pool, corsOrigin: 'http://localhost:5173' });
+ const without = createApp({ pool });
+ const preflight = (y: typeof withCors) =>
+ y.fetch('http://localhost/graphql', {
+ method: 'OPTIONS',
+ headers: {
+ origin: 'http://localhost:5173',
+ 'access-control-request-method': 'POST',
+ 'access-control-request-headers': 'content-type,x-user-id',
+ },
+ });
+ const a = await preflight(withCors);
+ expect(a.headers.get('access-control-allow-origin')).toBe('http://localhost:5173');
+ expect(a.headers.get('access-control-allow-headers')).toMatch(/x-user-id/i);
+ expect((await preflight(without)).headers.get('access-control-allow-origin')).toBeNull();
+ });
+
+ it('tåler ugyldig JSON-body og manglende query', async () => {
+ const yoga = createApp({ pool });
+ const bad = await yoga.fetch('http://localhost/graphql', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: '{ikke json',
+ });
+ expect(bad.status).toBeGreaterThanOrEqual(400);
+ expect(bad.status).toBeLessThan(500);
+ const empty = await yoga.fetch('http://localhost/graphql', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: '{}',
+ });
+ expect(empty.status).toBeLessThan(500);
+ });
+});
diff --git a/backend/test/title.test.ts b/backend/test/title.test.ts
new file mode 100644
index 0000000..9fe0425
--- /dev/null
+++ b/backend/test/title.test.ts
@@ -0,0 +1,194 @@
+/* eslint-disable @typescript-eslint/no-explicit-any -- dynamiske GraphQL-svar */
+import { describe, expect, it, vi } from 'vitest';
+import { setupApi, type GqlResult, USER_A, USER_B } from './helpers.js';
+import { SEARCH } from './queries.js';
+
+const env = setupApi();
+
+const TITLE = /* GraphQL */ `
+ query Title($id: ID!, $first: Int, $after: String) {
+ title(id: $id) {
+ id
+ primaryTitle
+ type
+ startYear
+ endYear
+ runtimeMinutes
+ genres
+ averageRating
+ numVotes
+ userRating
+ reviewCount
+ inMyList
+ reviews(first: $first, after: $after) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ edges {
+ cursor
+ node {
+ id
+ titleId
+ author
+ rating
+ text
+ createdAt
+ isMine
+ }
+ }
+ }
+ }
+ }
+`;
+
+describe('title', () => {
+ it('returnerer detaljer', async () => {
+ const res = await env.gql(TITLE, { id: 'tt0000001' });
+ expect(res.errors).toBeUndefined();
+ expect(res.data!.title).toMatchObject({
+ id: 'tt0000001',
+ primaryTitle: 'The Dark Knight',
+ type: 'MOVIE',
+ startYear: 2008,
+ endYear: null,
+ genres: ['Action', 'Crime', 'Drama'],
+ averageRating: 9,
+ numVotes: 2800000,
+ userRating: null,
+ reviewCount: 0,
+ inMyList: false,
+ });
+ expect(res.data!.title.reviews).toEqual({
+ totalCount: 0,
+ pageInfo: { hasNextPage: false, endCursor: null },
+ edges: [],
+ });
+ });
+
+ it.each(['tt9999999', '', "'; DROP TABLE titles;--", 'x'.repeat(500), 'tt\u0000'])(
+ 'gir null (ikke feil) for ukjent id %j',
+ async (id) => {
+ const res = await env.gql(TITLE, { id });
+ expect(res.errors).toBeUndefined();
+ expect(res.data!.title).toBeNull();
+ },
+ );
+});
+
+describe('title: anmeldelser', () => {
+ const N = 25;
+
+ it('setter opp anmeldelser', async () => {
+ // Eksplisitte tidsstempler; hvert tredje deler tidsstempel for å teste tiebreak på id.
+ await env.pool.query(
+ `INSERT INTO reviews (title_id, user_id, author, rating, body, created_at)
+ SELECT 'tt0000002', CASE WHEN i % 5 = 0 THEN $1::uuid ELSE $2::uuid END,
+ 'Anmelder ' || i, 1 + i % 5, 'Tekst ' || i,
+ timestamptz '2025-01-01 12:00:00.123456+00' + ((i / 3) * interval '1 minute')
+ FROM generate_series(1, ${N}) AS i`,
+ [USER_A, USER_B],
+ );
+ const res = await env.gql(TITLE, { id: 'tt0000002', first: 50 }, USER_A);
+ expect(res.data!.title.reviewCount).toBe(N);
+ expect(res.data!.title.reviews.totalCount).toBe(N);
+ });
+
+ it('userRating er snittet (1–5) og reviewCount antallet', async () => {
+ const { rows } = await env.pool.query<{ avg: string }>(
+ "SELECT avg(rating) AS avg FROM reviews WHERE title_id = 'tt0000002'",
+ );
+ const res = await env.gql(TITLE, { id: 'tt0000002' });
+ expect(res.data!.title.userRating).toBeCloseTo(Number(rows[0]!.avg), 2);
+ expect(res.data!.title.reviewCount).toBe(N);
+ });
+
+ it('paginerer nyeste først uten duplikater og hull, også ved like tidsstempler', async () => {
+ const ids: string[] = [];
+ const stamps: string[] = [];
+ let after: string | null = null;
+ let pages = 0;
+ do {
+ const res: GqlResult = await env.gql(TITLE, { id: 'tt0000002', first: 4, after }, USER_A);
+ expect(res.errors).toBeUndefined();
+ const rev: Record = res.data!.title.reviews;
+ for (const e of rev.edges) {
+ ids.push(e.node.id);
+ stamps.push(e.node.createdAt);
+ expect(e.node.titleId).toBe('tt0000002');
+ }
+ after = rev.pageInfo.hasNextPage ? rev.pageInfo.endCursor : null;
+ pages++;
+ } while (after && pages < 50);
+ expect(ids).toHaveLength(N);
+ expect(new Set(ids).size).toBe(N);
+ expect(pages).toBe(Math.ceil(N / 4));
+ const { rows } = await env.pool.query<{ id: string }>(
+ "SELECT r.id::text AS id FROM reviews r WHERE r.title_id = 'tt0000002' ORDER BY r.created_at DESC, r.id DESC",
+ );
+ expect(ids).toEqual(rows.map((r) => r.id));
+ expect([...stamps].sort().reverse()).toEqual(stamps);
+ });
+
+ it('bruker first=10 som standard', async () => {
+ const res = await env.gql(TITLE, { id: 'tt0000002' });
+ expect(res.data!.title.reviews.edges).toHaveLength(10);
+ expect(res.data!.title.reviews.pageInfo.hasNextPage).toBe(true);
+ });
+
+ it('isMine gjelder bare brukeren som skrev anmeldelsen', async () => {
+ const asA = (await env.gql(TITLE, { id: 'tt0000002', first: 50 }, USER_A)).data!.title.reviews
+ .edges;
+ const asB = (await env.gql(TITLE, { id: 'tt0000002', first: 50 }, USER_B)).data!.title.reviews
+ .edges;
+ const anon = (await env.gql(TITLE, { id: 'tt0000002', first: 50 })).data!.title.reviews.edges;
+ const mineA = asA.filter((e: { node: { isMine: boolean } }) => e.node.isMine).length;
+ const mineB = asB.filter((e: { node: { isMine: boolean } }) => e.node.isMine).length;
+ expect(mineA).toBe(5);
+ expect(mineB).toBe(N - 5);
+ expect(anon.some((e: { node: { isMine: boolean } }) => e.node.isMine)).toBe(false);
+ });
+
+ it('createdAt er ISO 8601', async () => {
+ const res = await env.gql(TITLE, { id: 'tt0000002', first: 1 });
+ const iso = res.data!.title.reviews.edges[0].node.createdAt;
+ expect(new Date(iso).toISOString()).toBe(iso);
+ });
+
+ it.each([0, 51])('avviser reviews(first: %s)', async (first) => {
+ const res = await env.gql(TITLE, { id: 'tt0000002', first });
+ expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ });
+
+ it.each(['abc', 'e30', Buffer.from('{"s":"reviews","v":["nei","1"]}').toString('base64url')])(
+ 'avviser ugyldig reviews-cursor %j',
+ async (after) => {
+ const res = await env.gql(TITLE, { id: 'tt0000002', after });
+ expect(res.errors?.[0]?.extensions?.code).toBe('BAD_USER_INPUT');
+ },
+ );
+});
+
+describe('N+1', () => {
+ it('henter statistikk og listestatus for en hel resultatside i én spørring hver', async () => {
+ const spy = vi.spyOn(env.pool, 'query');
+ const res = await env.gql(
+ `{ search(first: 30) { totalCount edges { node { id genres userRating reviewCount inMyList } } } }`,
+ undefined,
+ USER_A,
+ );
+ expect(res.errors).toBeUndefined();
+ expect(res.data!.search.edges).toHaveLength(30);
+ // hovedspørring + count + reviewStats + inMyList
+ expect(spy.mock.calls.length).toBeLessThanOrEqual(4);
+ spy.mockRestore();
+ });
+
+ it('gjør ikke ekstra spørringer for felt som ikke etterspørres', async () => {
+ const spy = vi.spyOn(env.pool, 'query');
+ await env.gql(SEARCH, { first: 20 });
+ expect(spy.mock.calls.length).toBe(2); // rader + totalCount
+ spy.mockRestore();
+ });
+});
diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts
index e14cc59..f7894cd 100644
--- a/backend/vitest.config.ts
+++ b/backend/vitest.config.ts
@@ -4,6 +4,7 @@ export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts', 'test/**/*.test.ts', 'scripts/**/*.test.ts'],
+ globalSetup: ['test/global-setup.ts'],
// Testene deler én testdatabase; parallelle filer ville tråkket på hverandre.
fileParallelism: false,
passWithNoTests: true,
diff --git a/docs/beslutninger.md b/docs/beslutninger.md
index 21a3770..8bbf54e 100644
--- a/docs/beslutninger.md
+++ b/docs/beslutninger.md
@@ -15,3 +15,60 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst.
- **Syntetisk testdatasett.** IMDb-nedlasting er blokkert i miljøet (se `BLOCKERS.md`). Et skript
genererer filer i nøyaktig samme TSV-format, så importkoden er den samme for ekte og syntetiske
data.
+- **Egen migreringsrunner.** `backend/scripts/migrate.ts` (tabell `schema_migrations`, én transaksjon per
+ fil, advisory lock mot samtidige kjøringer) i stedet for et bibliotek: vi trenger bare «kjør nye
+ SQL-filer i rekkefølge», og testene gjenbruker samme funksjon til å migrere testdatabasen.
+- **Denormalisert `titles.genres text[]` (GIN) og lagret `start_decade`.** `title_genres`/`genres` finnes
+ som normalisert kilde, men sjangerfilter (`genres @> ARRAY[…]`, «alle valgte») og fasettene blir
+ enklere og raskere uten join, og sjangre til en hel resultatside følger med raden (ingen N+1).
+ `start_decade` er en generert kolonne slik at tiårfilter er en likhet på en indeksert kolonne.
+ Prisen er at importen skriver sjangre to steder (i samme transaksjon).
+- **Søk med `lower(col) LIKE lower('%…%')` og trigram-GIN på `lower(...)`-uttrykk,** ikke `ILIKE`.
+ Indeksuttrykket må være identisk med spørringen for å bli brukt. Brukerens `%`, `_` og `\` escapes
+ slik at de er vanlige tegn. Aksenter foldes ikke (`cafe` finner ikke `Café`); `unaccent`
+ er ikke tatt med for å slippe en ekstra utvidelse.
+- **Keyset-paginering med radsammenligning.** Alle sorteringsnøkler går i samme retning
+ (`(a, b, id) < ($1,$2,$3)`), siste nøkkel er `id` (unik), og nullable kolonner erstattes av
+ sentinel via `COALESCE` (rating → -1, år → 0). Det gir stabil paginering uten hull/duplikater og lar
+ btree-indekser på samme uttrykk brukes. Konsekvens: titler uten rating/år havner sist ved DESC og
+ først ved ASC. Rating og år har `num_votes` som sekundær nøkkel (populære titler først blant like).
+ Cursoren inneholder en signatur (sortering/retning/om det finnes søketekst) og avvises
+ (`BAD_USER_INPUT`) hvis den brukes med en annen sortering. Tidsstempler i cursorer går som tekst
+ (mikrosekund-presisjon; JS `Date` har bare millisekunder).
+- **Relevans = `GREATEST(similarity(primær), similarity(original))`,** tiebreak `num_votes`, så `id`.
+ Uten søketekst betyr «relevans» flest stemmer først.
+- **`totalCount` er lazy** (beregnes bare hvis feltet etterspørres) og kjøres som egen `count(*)`.
+ `userRating`/`reviewCount`/`inMyList` hentes med en liten per-request batch-loader (`loaders.ts`),
+ så en side med 20 titler koster 1 spørring per felttype i stedet for 20.
+- **Fasettene** gir kun verdier med treff > 0, pluss valgte verdier med 0 (så et valgt filter ikke
+ forsvinner fra UI-et). Sortering: sjangre etter antall (synkende), tiår kronologisk, typer
+ `MOVIE`, `SERIES`. Titler uten startår mangler i tiårsfasetten.
+- **Feilkoder og maskering.** Egne feil er `GraphQLError` med `extensions.code`; uventede feil
+ (databasen nede, bugs) maskeres til «Intern feil.» med `INTERNAL_SERVER_ERROR`, og detaljene logges
+ bare på serveren. `title(id)` for ukjent id gir `null` (typen er nullable); `addReview`/`toggleList`
+ på ukjent tittel gir `NOT_FOUND`. `query` som inneholder NUL-tegn avvises som `BAD_USER_INPUT`
+ fordi Postgres ikke kan lagre dem. `minRating: 0` betyr «ingen grense».
+- **Spørredybde 6 og introspeksjon.** Egen valideringsregel (`depthLimit.ts`, rotfelt = nivå 0, som
+ `graphql-depth-limit`), fordi dagens skjema ikke kan nøstes dypere enn 6; regelen er et vern mot
+ fremtidige sirkulære relasjoner og testes mot et sirkulært testskjema. Introspeksjon og GraphiQL
+ slås av med `NODE_ENV=production` (egen regel, fordi graphqls innebygde regel feiler når `graphql`
+ lastes både som ESM og CJS). CORS er kun på utenfor produksjon (Apache gir same-origin i prod).
+- **Tester mot ekte Postgres** (`project2_test`), nullstilt og migrert i `globalSetup` – ingen
+ databasemocking, slik at SQL, indekser og migrasjoner testes sammen. Testene nekter å kjøre hvis
+ `TEST_DATABASE_URL` er lik `DATABASE_URL`.
+- **Import: rating-fila leses først** og bare rader over stemmegrensen holdes i et `Map`; `basics`
+ strømmes deretter mot dette. Enklere enn staging-tabell/COPY og raskt nok (120 000 titler på ca. 17 s).
+ Titler som senere faller under stemmegrensen fjernes ikke ved omkjøring (upsert, ingen sletting).
+- **Frontend: URL som eneste kilde til søketilstand.** `q`, `genres`, `decades`, `types` (`film`/`serie`),
+ `minRating`, `sort` (`relevans`/`rating`/`ar`/`tittel`), `dir` (`asc`/`desc`) leses og skrives med
+ `useSearchParams` (`replace`, så historikken ikke fylles). Søketeksten debounces i `SearchBox` og
+ skrives til URL-en først når den (trimmet) avviker fra forrige verdi; requesten følger av URL-en.
+- **Frontend: scrollgjenoppretting med `ScrollRestoration`** fra react-router (data router). Apollo-cachen
+ (`relayStylePagination` med keyArgs `query/filters/sort`) holder alle innlastede sider, så «tilbake»
+ har full listehøyde å scrolle til uten ny request.
+- **Frontend: fasetter og sjangerliste er egne queries** (`facets`, `genres`), atskilt fra `search`, slik at
+ sortering/sidebytte ikke henter fasetter på nytt. Forrige fasetter vises mens nye lastes.
+- **Frontend: ny anmeldelse vises via `refetch` av tittelen** etter `addReview` (serveren er kilden til
+ snitt og antall). «Min liste» bruker `cache-and-network` og fjerner rader lokalt i cachen ved «Fjern».
+- **Frontend: ingen nye biblioteker.** Bundle ca. 166 kB gzip initielt (React + Apollo + React Router);
+ detalj- og listesiden er lazy (ca. 4 kB gzip til sammen).
diff --git a/docs/oppsett.md b/docs/oppsett.md
new file mode 100644
index 0000000..8e50818
--- /dev/null
+++ b/docs/oppsett.md
@@ -0,0 +1,142 @@
+# Oppsett
+
+Krever Node.js ≥ 20 (testet med 22) og PostgreSQL ≥ 14 med `pg_trgm` (følger med standard
+`postgresql-contrib`; i Debian/Ubuntu-pakken `postgresql-contrib`, i Homebrew og offisielle
+Docker-bilder er den med).
+
+## 1. Installer avhengigheter
+
+```bash
+npm ci
+```
+
+## 2. PostgreSQL
+
+Opprett to databaser: én til appen og én til testene (testene **tømmer** sin database).
+
+```bash
+sudo -u postgres createdb project2
+sudo -u postgres createdb project2_test
+```
+
+Bruker du en egen bruker med passord: `sudo -u postgres psql -c "CREATE USER it2810 PASSWORD 'hemmelig' SUPERUSER;"`
+(`SUPERUSER` trengs bare fordi migrasjonen kjører `CREATE EXTENSION pg_trgm`; alternativt kjør
+`CREATE EXTENSION pg_trgm;` selv som superuser i begge databasene og gi vanlig bruker eierskap.)
+
+## 3. Miljøvariabler
+
+```bash
+cp .env.example .env
+```
+
+Rediger `DATABASE_URL` og `TEST_DATABASE_URL` hvis du ikke bruker `postgres` uten passord på
+`localhost:5432`. Uten `.env` gjelder disse standardverdiene:
+
+| Variabel | Standard |
+| ------------------- | -------------------------------------------------- |
+| `PORT` | `3001` |
+| `DATABASE_URL` | `postgres://postgres@localhost:5432/project2` |
+| `TEST_DATABASE_URL` | `postgres://postgres@localhost:5432/project2_test` |
+| `CORS_ORIGIN` | `http://localhost:5173` (bare utenfor produksjon) |
+| `IMDB_DATA_DIR` | `./data` (relativt til repo-roten) |
+| `IMDB_MIN_VOTES` | `100` |
+| `NODE_ENV` | `production` slår av introspeksjon og GraphiQL |
+
+`.env` leses fra repo-roten uansett hvilken mappe kommandoene kjøres fra. Variabler som allerede er
+satt i skallet (eller i CI) går foran `.env`.
+
+## 4. Skjema
+
+```bash
+npm run db:migrate
+```
+
+Kjører alle filene i `backend/migrations/` som ikke er kjørt (sporet i tabellen `schema_migrations`).
+Trygt å kjøre flere ganger. Importskriptet (`db:seed`) kjører også migrasjonene først.
+
+## 5. Data
+
+Importen trenger to filer i `IMDB_DATA_DIR` (standard `data/`): `title.basics.tsv.gz` og
+`title.ratings.tsv.gz`. Velg ett av alternativene.
+
+### A. Ekte IMDb-data (ca. 200 MB nedlasting)
+
+Datasettene er gratis for ikke-kommersiell bruk, ingen nøkkel trengs
+().
+
+```bash
+mkdir -p data
+wget -P data https://datasets.imdbws.com/title.basics.tsv.gz
+wget -P data https://datasets.imdbws.com/title.ratings.tsv.gz
+```
+
+(`curl -L -o data/title.basics.tsv.gz https://datasets.imdbws.com/title.basics.tsv.gz` fungerer også.)
+
+### B. Syntetiske data (ingen nedlasting)
+
+For utvikling og CI, eller når `datasets.imdbws.com` ikke er tilgjengelig:
+
+```bash
+npm run db:fixture -w backend # ca. 120 000 titler, deterministisk
+FIXTURE_SIZE=5000 npm run db:fixture -w backend # mindre sett
+```
+
+Filene har nøyaktig samme format som IMDb sine (samme kolonner, `\N` for null) og inneholder
+episoder, kortfilmer og titler med for få stemmer som importen skal filtrere bort, i tillegg til
+noen kjente titler (`tt0111161` The Shawshank Redemption, `tt0068646` The Godfather,
+`tt0468569` The Dark Knight, `tt0903747` Breaking Bad …). Datafilene er i `.gitignore`.
+
+### Importer
+
+```bash
+npm run db:seed
+```
+
+Beholder filmer (`movie`) og serier (`tvSeries`, `tvMiniSeries`) med minst `IMDB_MIN_VOTES` stemmer.
+Filene strømmes; minnebruken er lav. Skriptet kan kjøres på nytt (upsert), og resultatet er det
+samme. Ekte data gir omtrent 100–500 tusen titler avhengig av grensen, og tar noen minutter.
+Mangler filene, gir skriptet en feilmelding som peker hit.
+
+## 6. Kjør
+
+```bash
+npm run dev -w backend # API på http://localhost:3001/graphql (GraphiQL i nettleseren)
+npm run dev # backend + frontend sammen (fra roten)
+```
+
+Prøv API-et:
+
+```bash
+curl -s localhost:3001/graphql -H 'content-type: application/json' \
+ -d '{"query":"{ search(query: \"dark\", first: 3) { totalCount edges { node { id primaryTitle startYear } } } }"}'
+```
+
+Mutations krever headeren `x-user-id: `.
+
+Produksjonsbygg av backend:
+
+```bash
+npm run build -w backend
+NODE_ENV=production npm start -w backend
+```
+
+## 7. Test og kvalitetssjekk
+
+```bash
+npm run lint # eslint + prettier --check
+npm run typecheck # alle pakker
+npm test # alle pakker; backend-testene trenger TEST_DATABASE_URL
+npm test -w backend # bare backend
+```
+
+Backend-testene nullstiller `TEST_DATABASE_URL`-databasen (`DROP SCHEMA public CASCADE`), migrerer den
+fra scratch og setter inn et lite kontrollert datasett. De nekter å kjøre hvis
+`TEST_DATABASE_URL` er lik `DATABASE_URL`. I GitLab CI peker `TEST_DATABASE_URL` på en tom
+`postgres:16`-tjeneste (se `.gitlab-ci.yml`); ingen forberedelser trengs.
+
+## Feilsøking
+
+- `permission denied to create extension "pg_trgm"`: kjør migrasjonen som superuser, eller kjør
+ `CREATE EXTENSION pg_trgm;` som superuser i databasen først.
+- `Fant ikke datafilene`: gjør steg 5A eller 5B.
+- `password authentication failed`: sett riktig bruker/passord i `DATABASE_URL` / `TEST_DATABASE_URL`.
diff --git a/docs/ytelse.md b/docs/ytelse.md
new file mode 100644
index 0000000..2acaf00
--- /dev/null
+++ b/docs/ytelse.md
@@ -0,0 +1,86 @@
+# Ytelse
+
+## Databasesøk: `EXPLAIN ANALYZE` før og etter indekser
+
+> **Merk:** Tallene er målt på det syntetiske datasettet (`npm run db:fixture -w backend`:
+> 120 000 titler, PostgreSQL 16, lokal maskin, `jit = off`, andre kjøring/varm cache). Med de ekte
+> IMDb-dataene (flere titler, andre tittelfordelinger) blir absolutt-tidene annerledes, men forholdet
+> mellom «uten indeks» og «med indeks» skal være det samme. Målingen kan gjentas med
+> et `EXPLAIN (ANALYZE, BUFFERS)` på spørringene under.
+
+«Før» er målt ved å slette alle indekser på `titles` unntatt primærnøkkelen inne i en transaksjon som
+rulles tilbake (`BEGIN; DROP INDEX …; ANALYZE titles; EXPLAIN ANALYZE …; ROLLBACK;`), slik at
+databasen er uendret etterpå. «Etter» er skjemaet fra `backend/migrations/001_init.sql`.
+
+Spørringene er de samme som API-et bygger (`backend/src/search.ts`), med `LIMIT 21` (= `first: 20` + 1
+for å avgjøre `hasNextPage`).
+
+| Scenario | Uten indekser (ms) | Med indekser (ms) | Plan etter |
+| -------------------------------------------------------------- | -----------------: | ----------------: | ---------------------------------------------------------- |
+| Søk «dark» (1 774 treff), sortert på rating, `LIMIT 21` | 76,7 | 3,1 | Index Scan Backward `titles_rating_idx` |
+| Søk «dark», sortert på relevans (`similarity`), `LIMIT 21` | 82,4 | 8,9 | Bitmap OR av begge trigram-indeksene + top-N sort |
+| `count(*)` for «dark» (`totalCount`) | 76,3 | 3,0 | Bitmap OR av begge trigram-indeksene |
+| Ingen søketekst, sortert på rating, `LIMIT 21` | 24,2 | 0,03 | Index Scan Backward `titles_rating_idx` |
+| Filter sjanger Drama+Crime, tiår 1990/2000, sortert på stemmer | 25,6 | 2,5 | Index Scan Backward `titles_votes_idx` (filter i skanning) |
+
+### Tolkning
+
+- **Uten indekser** leser Postgres alle 120 000 rader (`Seq Scan`) og evaluerer `lower(...) LIKE '%dark%'`
+ på begge titlene for hver rad. Kostnaden vokser lineært med tabellen, så ~77 ms her blir mange
+ hundre ms med flere titler, og den betales for hver tastetrykk-debounce og for hver `totalCount`.
+- **Trigram-GIN** (`gin (lower(primary_title) gin_trgm_ops)` og tilsvarende for `original_title`) gjør
+ at `LIKE '%…%'` går via indeksen (bitmap-skann) i stedet for å lese hele tabellen. Uttrykket i
+ spørringen må være identisk med indeksuttrykket (`lower(t.primary_title)`), ellers brukes ikke indeksen.
+ Derfor lager vi `lower()`-indekser i stedet for å bruke `ILIKE` på råkolonnen.
+- **Sortering på rating/år/stemmer/tittel** dekkes av btree-indekser på nøyaktig samme uttrykk og
+ kolonnerekkefølge som `ORDER BY` og cursor-sammenligningen (`(COALESCE(average_rating,-1), num_votes, id)`).
+ Postgres kan da hente de 21 første radene direkte fra indeksen uten å sortere, og keyset-paginering
+ (`(a, b, id) < ($1, $2, $3)`) bruker samme indeks for side 2, 3, … uten OFFSET-kostnad.
+- **Sjanger- og tiårfilter** bruker GIN på `genres text[]` (`@>`) og `start_decade`. Her velger
+ planleggeren likevel å gå baklengs på stemmeindeksen og filtrere underveis, fordi mange rader
+ oppfyller filteret og 21 treff finnes raskt. Med et sjeldnere filter tar bitmap-skannet over.
+- **Relevanssortering** må beregne `similarity()` for alle treff før den kan sortere, så den er dyrere
+ (8,9 ms) enn sortering på lagret kolonne, men trigram-indeksen holder antall rader nede
+ (1 774 av 120 000). Ved svært vanlige søkeord («the») er dette den tregeste spørringen.
+- **Grenser vi kjenner til:** søketekst på 1–2 tegn har færre enn ett trigram og kan ikke bruke
+ GIN-indeksen (Postgres faller tilbake til sekvensiell skanning, ca. samme tid som «uten indeks»).
+ `totalCount` og fasetter regnes ut på hele treffmengden hver gang og er derfor den dyreste delen
+ av et bredt søk; `totalCount` beregnes bare når klienten ber om feltet.
+
+### Rådata (utdrag)
+
+Søk «dark», sortert på rating. Uten indekser:
+
+```
+Limit (actual time=76.668..76.673 rows=21 loops=1)
+ -> Sort Sort Key: (COALESCE(average_rating, '-1'::numeric)) DESC, num_votes DESC, id DESC
+ Sort Method: top-N heapsort Memory: 27kB
+ -> Seq Scan on titles t (actual time=0.032..75.964 rows=1774 loops=1)
+Execution Time: 76.697 ms
+```
+
+Med indekser:
+
+```
+Limit (actual time=0.052..3.065 rows=21 loops=1)
+ -> Index Scan Backward using titles_rating_idx on titles t (actual time=0.051..3.060 rows=21 loops=1)
+Execution Time: 3.083 ms
+```
+
+Søk «dark», relevans, med indekser:
+
+```
+Limit (actual time=8.910..8.915 rows=21 loops=1)
+ -> Sort Sort Key: (GREATEST(similarity(lower(primary_title), 'dark'), similarity(lower(original_title), 'dark'))) DESC, num_votes DESC, id DESC
+ -> Bitmap Heap Scan on titles t (actual time=0.575..8.616 rows=1774 loops=1)
+ -> BitmapOr
+ -> Bitmap Index Scan on titles_primary_title_trgm_idx (rows=1774)
+ -> Bitmap Index Scan on titles_original_title_trgm_idx (rows=1600)
+Execution Time: 8.949 ms
+```
+
+## Importtid
+
+`npm run db:seed -w backend` importerer 120 000 syntetiske titler (162 000 linjer i `basics`) på ca.
+17 s, inkludert oppdatering av alle indekser. Fila strømmes (gunzip + readline) og skrives i
+batcher på 2 000 rader; bare rating-rader over stemmegrensen holdes i minnet.
From 47504d95aef684f6099b3515e66edf21f0a97e4f Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:27:55 +0000
Subject: [PATCH 08/29] fix(backend): rank popular titles higher in relevance
sort
---
backend/src/search.ts | 10 ++++++++--
backend/test/pagination.test.ts | 7 +++++--
frontend/index.html | 2 ++
3 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/backend/src/search.ts b/backend/src/search.ts
index 05354ee..095225b 100644
--- a/backend/src/search.ts
+++ b/backend/src/search.ts
@@ -110,8 +110,14 @@ function sortKeys(field: SortField, query: string | null, p: Params): KeyDef[] {
case 'RELEVANCE': {
if (!query) return [VOTES, ID];
const q = `lower(${p.add(query)}::text)`;
- // Høyeste likhet mot primær- eller originaltittel; stemmer og id som tiebreak.
- const rel = `GREATEST(similarity(lower(t.primary_title), ${q}), similarity(lower(t.original_title), ${q}))`;
+ // Likhet alene rangerer obskure titler med identisk navn over klassikerne brukeren som regel
+ // mener («Dark Knight», 1 500 stemmer, før «The Dark Knight»). word_similarity gir full score
+ // når søkeordene står i tittelen, likhet belønner eksakte treff, og popularitet (log10 av
+ // stemmer, mettet ved 10 mill.) skiller resten. Stemmer og id er tiebreak.
+ const wordSim = `GREATEST(word_similarity(${q}, lower(t.primary_title)), word_similarity(${q}, lower(t.original_title)))`;
+ const sim = `GREATEST(similarity(lower(t.primary_title), ${q}), similarity(lower(t.original_title), ${q}))`;
+ const pop = `LEAST(log(GREATEST(t.num_votes, 1)) / 7.0, 1.0)`;
+ const rel = `(0.6 * ${wordSim} + 0.2 * ${sim} + 0.2 * ${pop})`;
return [{ expr: rel, select: `(${rel})::float8`, cast: 'float8', kind: 'float' }, VOTES, ID];
}
case 'RATING':
diff --git a/backend/test/pagination.test.ts b/backend/test/pagination.test.ts
index a7b106a..d3259ac 100644
--- a/backend/test/pagination.test.ts
+++ b/backend/test/pagination.test.ts
@@ -73,10 +73,13 @@ describe('paginering og sortering', () => {
},
);
- it('relevans med søketekst er monotont synkende i likhet, deretter stemmer', async () => {
+ it('relevans med søketekst følger vektet ord-likhet, likhet og popularitet, deretter stemmer', async () => {
+ // Uavhengig gjengivelse av formelen i search.ts – fanger opp utilsiktede endringer i rangeringen.
const { rows } = await env.pool.query<{ id: string; rel: number; num_votes: number }>(
`SELECT id, num_votes,
- GREATEST(similarity(lower(primary_title), 'dark'), similarity(lower(original_title), 'dark'))::float8 AS rel
+ (0.6 * GREATEST(word_similarity('dark', lower(primary_title)), word_similarity('dark', lower(original_title)))
+ + 0.2 * GREATEST(similarity(lower(primary_title), 'dark'), similarity(lower(original_title), 'dark'))
+ + 0.2 * LEAST(log(GREATEST(num_votes, 1)) / 7.0, 1.0))::float8 AS rel
FROM titles WHERE lower(primary_title) LIKE '%dark%' OR lower(original_title) LIKE '%dark%'
ORDER BY rel DESC, num_votes DESC, id DESC`,
);
diff --git a/frontend/index.html b/frontend/index.html
index 8d1bd48..0f48a42 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,6 +5,8 @@
+
+
Filmsøk – IT2810 gruppe 17
From 43052d2ed1265943a8748fdbfc6910c5d4d89b05 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:43:59 +0000
Subject: [PATCH 09/29] fix(frontend): prevent layout shift from footer and
scrollbar
---
frontend/src/styles/global.css | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index b79c29c..bb1bad5 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -42,6 +42,12 @@
}
}
+/* Reserverer plass til rullefeltet. Uten dette dukker det opp når resultatlisten vokser forbi
+ viewporten, og hele siden flytter seg sideveis (var mesteparten av CLS på desktop). */
+html {
+ scrollbar-gutter: stable;
+}
+
*,
*::before,
*::after {
@@ -52,10 +58,17 @@ body {
margin: 0;
background: var(--bg);
color: var(--text);
+ overflow-wrap: anywhere;
+}
+
+/* #root (ikke body) er flex-beholderen, siden Layout er dens direkte barn. Da fyller main
+ viewporten fra første bilde, og footeren ligger nederst allerede før innholdet er lastet.
+ Uten dette skjøt det lastede innholdet footeren og main nedover (CLS 0,5-0,9). Flex-elementer
+ har egen margin-kontekst, så barnets marger flytter heller ikke main. */
+#root {
min-height: 100vh;
display: flex;
flex-direction: column;
- overflow-wrap: anywhere;
}
h1 {
From 50cb89d7600689d13c606c8e20067527d2f5b261 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:44:00 +0000
Subject: [PATCH 10/29] test: add Playwright end-to-end and axe accessibility
tests
---
.gitignore | 1 +
e2e/a11y.spec.ts | 46 +++++++++++++++++++
e2e/flow.spec.ts | 109 ++++++++++++++++++++++++++++++++++++++++++++
e2e/global-setup.ts | 40 ++++++++++++++++
package.json | 2 +-
tsconfig.json | 10 ++++
6 files changed, 207 insertions(+), 1 deletion(-)
create mode 100644 e2e/a11y.spec.ts
create mode 100644 e2e/flow.spec.ts
create mode 100644 e2e/global-setup.ts
create mode 100644 tsconfig.json
diff --git a/.gitignore b/.gitignore
index a1818f7..f4a5630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,4 @@ blob-report/
*.tsbuildinfo
.DS_Store
.claude/DONE.local
+data/e2e/
diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts
new file mode 100644
index 0000000..fb071a3
--- /dev/null
+++ b/e2e/a11y.spec.ts
@@ -0,0 +1,46 @@
+import { AxeBuilder } from '@axe-core/playwright';
+import { expect, test } from '@playwright/test';
+
+// WCAG 2.1 AA er kravet i CLAUDE.md; axe sjekker det som kan sjekkes automatisk.
+const pages = [
+ { name: 'søk', path: './', ready: 'Søk i filmer og serier' },
+ {
+ name: 'søk med treff og filter',
+ path: './?q=dark&types=film&sort=rating',
+ ready: 'Søk i filmer og serier',
+ },
+ { name: 'detalj', path: './title/tt0468569', ready: 'The Dark Knight' },
+ { name: 'min liste', path: './my-list', ready: 'Min liste' },
+ { name: 'ukjent side', path: './finnes/ikke', ready: null },
+];
+
+for (const { name, path, ready } of pages) {
+ for (const scheme of ['light', 'dark'] as const) {
+ test(`axe: ${name} (${scheme})`, async ({ page }) => {
+ await page.emulateMedia({ colorScheme: scheme });
+ await page.goto(path);
+ const h1 = page.getByRole('heading', { level: 1 });
+ if (ready) await expect(h1).toHaveText(ready);
+ else await expect(h1).toBeVisible();
+ // Vent til skjelett-lasting er ferdig så axe ser det endelige innholdet.
+ await expect(page.locator('[aria-busy="true"]')).toHaveCount(0);
+
+ const results = await new AxeBuilder({ page })
+ .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
+ .analyze();
+ expect(results.violations.map((v) => `${v.id}: ${v.nodes.length} noder`)).toEqual([]);
+ });
+ }
+}
+
+test('ingen horisontal scroll ved 320 px', async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 640 });
+ for (const path of ['./?q=dark', './title/tt0468569', './my-list']) {
+ await page.goto(path);
+ await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ );
+ expect(overflow, path).toBeLessThanOrEqual(0);
+ }
+});
diff --git a/e2e/flow.spec.ts b/e2e/flow.spec.ts
new file mode 100644
index 0000000..dc59e3f
--- /dev/null
+++ b/e2e/flow.spec.ts
@@ -0,0 +1,109 @@
+import { expect, test } from '@playwright/test';
+
+// Hele hovedflyten fra PLAN.md (M4) i én test, fordi hvert steg bygger på tilstanden fra det forrige:
+// søk → filtrer → sorter → scroll → detalj → skriv anmeldelse → se den i lista.
+test('søk, filtrer, sorter, scroll, åpne detalj og skriv anmeldelse', async ({ page }) => {
+ await page.goto('./');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('Søk i filmer og serier');
+ const count = page.locator('.count[role="status"]');
+ await expect(count).toHaveText(/\d[\d\s]* treff/);
+
+ // Søk: debouncet og case-insensitivt.
+ await page.getByLabel('Søk etter tittel').fill('THE');
+ await expect(page).toHaveURL(/q=THE/);
+ const countAfterSearch = await count.textContent();
+
+ // Filtrer: type Film. Antallet skal endre seg og filteret vises som chip.
+ const filters = page.getByRole('complementary', { name: 'Filtre' });
+ const toggle = filters.getByRole('button', { name: /^Filtre/ });
+ if (await toggle.isVisible()) {
+ if ((await toggle.getAttribute('aria-expanded')) === 'false') await toggle.click();
+ }
+ await filters.getByRole('checkbox', { name: /^Film/ }).check();
+ await expect(page).toHaveURL(/types=film/);
+ await expect(count).not.toHaveText(countAfterSearch ?? '');
+ await expect(page.getByRole('list', { name: 'Aktive filtre' })).toContainText('Film');
+
+ // Sorter på rating synkende: første kort skal ha minst like høy rating som det andre.
+ await page.getByLabel('Sorter etter').selectOption({ label: 'Rating' });
+ await expect(page).toHaveURL(/sort=rating/);
+ const cards = page.locator('main article');
+ await expect(cards.first()).toBeVisible();
+ const ratings = await cards.evaluateAll((els) =>
+ els
+ .slice(0, 5)
+ .map((el) => Number((el.textContent ?? '').match(/★\s*([\d,]+)/)?.[1]?.replace(',', '.'))),
+ );
+ for (let i = 1; i < ratings.length; i++)
+ expect(ratings[i - 1]!).toBeGreaterThanOrEqual(ratings[i]!);
+
+ // Scroll: flere resultater lastes inn (uendelig scroll eller «Last flere»).
+ const before = await cards.count();
+ const loadMore = page.getByRole('button', { name: 'Last flere' });
+ await page.mouse.wheel(0, 20000);
+ await expect
+ .poll(async () => {
+ if ((await cards.count()) > before) return true;
+ if (await loadMore.isVisible()) await loadMore.click();
+ return (await cards.count()) > before;
+ })
+ .toBe(true);
+
+ // Detalj.
+ const firstLink = cards.first().getByRole('link');
+ const title = (await firstLink.textContent())?.trim() ?? '';
+ await firstLink.click();
+ await expect(page).toHaveURL(/\/title\/tt\d+/);
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText(title);
+
+ // Skriv anmeldelse med spesialtegn og se den øverst i lista.
+ const text = `Fantastisk – «${Date.now()}» ikke html 🎬`;
+ await page.getByLabel('Navn').fill('E2E-tester');
+ await page.getByRole('radio', { name: '4 stjerner' }).check();
+ await page.getByRole('textbox', { name: /^Anmeldelse/ }).fill(text);
+ await page.getByRole('button', { name: 'Send anmeldelse' }).click();
+ await expect(page.getByRole('status').filter({ hasText: /takk|lagt til|sendt/i })).toBeVisible();
+ await expect(page.getByText(text)).toBeVisible();
+
+ // Tilbake: søket i URL-en og resultatene er bevart.
+ await page.goBack();
+ await expect(page).toHaveURL(/q=THE.*types=film|types=film.*q=THE/);
+ await expect(page.getByLabel('Søk etter tittel')).toHaveValue('THE');
+});
+
+test('min liste: legg til fra detaljsiden og fjern igjen', async ({ page }) => {
+ await page.goto('./title/tt0468569');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('The Dark Knight');
+ await page.getByRole('button', { name: 'Legg i min liste' }).click();
+ await expect(page.getByRole('button', { name: 'Fjern fra min liste' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ );
+
+ await page
+ .getByRole('navigation', { name: 'Hovedmeny' })
+ .getByRole('link', { name: 'Min liste' })
+ .click();
+ await expect(page.getByRole('link', { name: 'The Dark Knight' })).toBeVisible();
+ await page.getByRole('button', { name: /Fjern/ }).first().click();
+ await expect(page.getByRole('link', { name: 'The Dark Knight' })).toHaveCount(0);
+});
+
+test('ingen treff og direkte lenke til ukjent side', async ({ page }) => {
+ await page.goto('./?q=' + encodeURIComponent('zzqx%_\'"finnesikke'));
+ await expect(page.locator('.count[role="status"]')).toHaveText(/^0 treff|Ingen treff/);
+
+ await page.goto('./title/tt0000000');
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(
+ /fant ikke|finnes ikke|ikke funnet/i,
+ );
+});
+
+test('tastatur: skip-link flytter fokus til hovedinnholdet', async ({ page }) => {
+ await page.goto('./');
+ await page.keyboard.press('Tab');
+ const skip = page.getByRole('link', { name: /Hopp til innhold/ });
+ await expect(skip).toBeFocused();
+ await page.keyboard.press('Enter');
+ await expect(page.locator('main')).toBeFocused();
+});
diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts
new file mode 100644
index 0000000..75504e7
--- /dev/null
+++ b/e2e/global-setup.ts
@@ -0,0 +1,40 @@
+import { execSync } from 'node:child_process';
+import path from 'node:path';
+import pg from 'pg';
+
+const E2E_DATABASE_URL =
+ process.env.E2E_DATABASE_URL ?? 'postgres://postgres@localhost:5432/project2_e2e';
+// Egen datamappe slik at et syntetisk datasett aldri overskriver ekte IMDb-filer i data/.
+const DATA_DIR = path.resolve(import.meta.dirname, '..', 'data', 'e2e');
+const FIXTURE_SIZE = '20000';
+
+/**
+ * Sørger for en E2E-database med kjent innhold: opprettes ved behov, migreres, fylles med det
+ * deterministiske syntetiske datasettet første gang, og tømmes for brukerdata hver kjøring slik at
+ * testene ikke avhenger av hverandre eller av forrige kjøring.
+ */
+export default async function globalSetup() {
+ const url = new URL(E2E_DATABASE_URL);
+ const dbName = url.pathname.slice(1);
+ const admin = new pg.Client({
+ connectionString: Object.assign(new URL(url), { pathname: '/postgres' }).toString(),
+ });
+ await admin.connect();
+ const exists = await admin.query('SELECT 1 FROM pg_database WHERE datname = $1', [dbName]);
+ if (exists.rowCount === 0) await admin.query(`CREATE DATABASE "${dbName.replace(/"/g, '""')}"`);
+ await admin.end();
+
+ const env = { ...process.env, DATABASE_URL: E2E_DATABASE_URL, IMDB_DATA_DIR: DATA_DIR };
+ const run = (cmd: string) => execSync(cmd, { env, stdio: 'inherit' });
+ run('npm run db:migrate -w backend');
+
+ const db = new pg.Client({ connectionString: E2E_DATABASE_URL });
+ await db.connect();
+ const { rows } = await db.query<{ n: string }>('SELECT count(*) AS n FROM titles');
+ if (Number(rows[0]?.n ?? 0) === 0) {
+ run(`npm run db:fixture -w backend -- ${FIXTURE_SIZE}`);
+ run('npm run db:seed -w backend');
+ }
+ await db.query('TRUNCATE reviews, list_items');
+ await db.end();
+}
diff --git a/package.json b/package.json
index 15462dc..88aa9d8 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"build": "npm run build -w backend && npm run build -w frontend",
"lint": "eslint . && prettier --check .",
"format": "prettier --write .",
- "typecheck": "npm run typecheck --workspaces",
+ "typecheck": "tsc -p tsconfig.json && npm run typecheck --workspaces",
"test": "npm test --workspaces",
"test:e2e": "playwright test",
"db:migrate": "npm run db:migrate -w backend",
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..c5a1436
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "types": ["node"],
+ "noEmit": true
+ },
+ "include": ["e2e", "playwright.config.ts"]
+}
From fdd6d0a2dce09c0de5c048530b4e24ddacd2b8fa Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:46:04 +0000
Subject: [PATCH 11/29] docs: add status summary, Lighthouse results and
complete plan
---
.claude/DONE | 1 +
PLAN.md | 54 ++++++++++++++++++++++++------------------------
docs/ki-logg.md | 8 +++++++
docs/status.md | 43 ++++++++++++++++++++++++++++++++++++++
docs/ytelse.md | 21 +++++++++++++++++++
e2e/flow.spec.ts | 15 ++++++++++++++
6 files changed, 115 insertions(+), 27 deletions(-)
create mode 100644 .claude/DONE
create mode 100644 docs/status.md
diff --git a/.claude/DONE b/.claude/DONE
new file mode 100644
index 0000000..c1f56f9
--- /dev/null
+++ b/.claude/DONE
@@ -0,0 +1 @@
+MVP ferdig og verifisert 2026-09-30. Se docs/status.md.
diff --git a/PLAN.md b/PLAN.md
index cdd0e16..144207a 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -10,50 +10,50 @@ Brukergenererte data: anmeldelser (1–5 stjerner + tekst) og "min liste".
## M0 – Oppsett
- [x] Monorepo med npm workspaces, TypeScript strict, ESLint, Prettier, Husky + lint-staged
-- [ ] Scripts i rot: `dev`, `build`, `lint`, `typecheck`, `test`, `test:e2e`, `db:migrate`, `db:seed`
+- [x] Scripts i rot: `dev`, `build`, `lint`, `typecheck`, `test`, `test:e2e`, `db:migrate`, `db:seed`
- [x] `.env.example`, `.gitignore`, `docs/beslutninger.md`, `docs/ki-logg.md`, `BLOCKERS.md`
- [x] GitLab CI (`.gitlab-ci.yml`): lint, typecheck, unit-tester
## M1 – Database og import
-- [ ] Skjema + migrasjoner (titles, genres, reviews, lists), indekser inkl. `pg_trgm` GIN på tittel
-- [ ] Importskript som strømmer TSV (ikke les alt i minnet) og kan kjøres på nytt (idempotent)
-- [ ] `EXPLAIN ANALYZE` for søk før/etter indeks lagret i `docs/ytelse.md`
+- [x] Skjema + migrasjoner (titles, genres, reviews, lists), indekser inkl. `pg_trgm` GIN på tittel
+- [x] Importskript som strømmer TSV (ikke les alt i minnet) og kan kjøres på nytt (idempotent)
+- [x] `EXPLAIN ANALYZE` for søk før/etter indeks lagret i `docs/ytelse.md`
## M2 – GraphQL-API
-- [ ] `search(query, filters, sort, first, after)` med connection-type (edges, pageInfo, totalCount)
-- [ ] `title(id)` med detaljer og anmeldelser
-- [ ] `facets(query, filters)` → antall treff per sjanger / tiår / type
-- [ ] Mutations: `addReview`, `toggleList` (anonym bruker-id via cookie/localStorage-uuid)
-- [ ] Input-validering, feilhåndtering, maks `first`, query-dybde begrenset
-- [ ] API-tester for alle queries/mutations inkl. kanttilfeller
+- [x] `search(query, filters, sort, first, after)` med connection-type (edges, pageInfo, totalCount)
+- [x] `title(id)` med detaljer og anmeldelser
+- [x] `facets(query, filters)` → antall treff per sjanger / tiår / type
+- [x] Mutations: `addReview`, `toggleList` (anonym bruker-id via cookie/localStorage-uuid)
+- [x] Input-validering, feilhåndtering, maks `first`, query-dybde begrenset
+- [x] API-tester for alle queries/mutations inkl. kanttilfeller
## M3 – Frontend
-- [ ] Søkefelt med debounce, case-insensitivt, tøm-knapp
-- [ ] Resultatliste med uendelig scroll + "Last flere"-knapp, skeleton-lasting
-- [ ] Filterpanel (sjanger, tiår, type, min. rating) med antall treff, aktive filtre som chips, nullstill
-- [ ] Sortering (relevans, rating, år, tittel) – alltid på server
-- [ ] Detaljside med anmeldelser og skjema for ny anmeldelse
-- [ ] "Min liste"-side
-- [ ] State i URL, tilbake-knapp bevarer scroll
-- [ ] Tomt/ingen treff/feil-tilstander med "prøv igjen"
-- [ ] Mørk modus (følger system), responsivt ned til 320 px
+- [x] Søkefelt med debounce, case-insensitivt, tøm-knapp
+- [x] Resultatliste med uendelig scroll + "Last flere"-knapp, skeleton-lasting
+- [x] Filterpanel (sjanger, tiår, type, min. rating) med antall treff, aktive filtre som chips, nullstill
+- [x] Sortering (relevans, rating, år, tittel) – alltid på server
+- [x] Detaljside med anmeldelser og skjema for ny anmeldelse
+- [x] "Min liste"-side
+- [x] State i URL, tilbake-knapp bevarer scroll
+- [x] Tomt/ingen treff/feil-tilstander med "prøv igjen"
+- [x] Mørk modus (følger system), responsivt ned til 320 px
## M4 – Kvalitet
-- [ ] Komponenttester for søk, liste, filter, detaljside, anmeldelsesskjema
-- [ ] Playwright E2E: søk → filtrer → sorter → scroll → detalj → skriv anmeldelse → se den i lista
-- [ ] axe-sjekk i Playwright uten feil på alle sider
-- [ ] Lighthouse ≥ 90 på alle kategorier (resultat i `docs/ytelse.md`)
-- [ ] Alle tester, lint og typecheck grønne fra ren klon (`npm ci`)
+- [x] Komponenttester for søk, liste, filter, detaljside, anmeldelsesskjema
+- [x] Playwright E2E: søk → filtrer → sorter → scroll → detalj → skriv anmeldelse → se den i lista
+- [x] axe-sjekk i Playwright uten feil på alle sider
+- [x] Lighthouse ≥ 90 på alle kategorier (resultat i `docs/ytelse.md`)
+- [x] Alle tester, lint og typecheck grønne fra ren klon (`npm ci`)
## M5 – Deploy-klargjøring (kjøres ikke mot VM uten Nicolay)
-- [ ] `docs/deploy.md`: Postgres-oppsett, systemd-fil for backend, Apache-konfig for `/project2`, gzip/cache-headere
-- [ ] `deploy/`-mappe med systemd-unit og Apache-konfig ferdig utfylt for `it2810-17.idi.ntnu.no`
+- [x] `docs/deploy.md`: Postgres-oppsett, systemd-fil for backend, Apache-konfig for `/project2`, gzip/cache-headere
+- [x] `deploy/`-mappe med systemd-unit og Apache-konfig ferdig utfylt for `it2810-17.idi.ntnu.no`
## Ferdig
-- [ ] Oppsummering i `docs/status.md`: hva er gjort, hva gjenstår, hva Nicolay må gjøre
+- [x] Oppsummering i `docs/status.md`: hva er gjort, hva gjenstår, hva Nicolay må gjøre
diff --git a/docs/ki-logg.md b/docs/ki-logg.md
index 15d65bb..fb15970 100644
--- a/docs/ki-logg.md
+++ b/docs/ki-logg.md
@@ -3,3 +3,11 @@
Én linje per fullført oppgave: dato – hva – hvem (agent) – verifisering.
- 2026-09-30 – M0 oppsett: monorepo, TS strict, ESLint/Prettier, Husky + lint-staged, GitLab CI – leder – lint, typecheck, test og build grønne.
+- 2026-09-30 – M1 database, migreringer, strømmende import og syntetisk datasett – backend-utvikler – 184 backend-tester grønne, EXPLAIN før/etter i ytelse.md.
+- 2026-09-30 – M2 GraphQL-API (search, title, facets, myList, addReview, toggleList, validering, dybdegrense) – backend-utvikler – API-tester grønne, leder gikk gjennom SQL-parameterisering.
+- 2026-09-30 – M3 frontend og komponenttester – frontend-utvikler – 74 tester grønne, leder testet mot ekte backend i nettleser.
+- 2026-09-30 – Relevanssortering vektet med popularitet (klassikere øverst) – leder – test oppdatert, 184 grønne.
+- 2026-09-30 – Layoutskift fikset, Lighthouse ≥ 94 overalt – frontend-utvikler – leder målte på nytt.
+- 2026-09-30 – M4 Playwright E2E + axe + 320 px + scroll-bevaring – leder – 32 E2E-tester grønne.
+- 2026-09-30 – M4 ren klon (`npm ci`): lint, typecheck, test og build grønne – leder.
+- 2026-09-30 – M5 deploy-skript, systemd og Apache – leder – ikke kjørt mot VM (krever Nicolay).
diff --git a/docs/status.md b/docs/status.md
new file mode 100644
index 0000000..17e1871
--- /dev/null
+++ b/docs/status.md
@@ -0,0 +1,43 @@
+# Status
+
+Oppdatert 2026-09-30.
+
+## Hva er gjort
+
+Alle punktene i `PLAN.md` er gjort og verifisert i utviklingsmiljøet.
+
+- **Backend:** PostgreSQL-skjema med migreringer, importskript som strømmer IMDb-filene og tåler å
+ kjøres på nytt, og GraphQL-API med søk, filtre, fasetter, sortering, cursor-paginering,
+ anmeldelser og «min liste». All søk, filtrering og sortering skjer i SQL.
+- **Frontend:** React + Apollo Client med søk (300 ms debounce), filterpanel med antall treff,
+ aktive filtre som chips, sortering, uendelig scroll + «Last flere», detaljside med
+ anmeldelsesskjema, «Min liste», state i URL, mørk modus og responsivt ned til 320 px.
+- **Tester:**
+ - 184 backend-tester (Vitest mot testdatabase)
+ - 74 komponenttester (Vitest + Testing Library)
+ - 32 E2E-tester (Playwright på desktop og mobil), blant annet hele hovedflyten, axe uten
+ brudd på alle sider i lys og mørk modus, og ingen horisontal scroll ved 320 px
+- **Ytelse:** Lighthouse ≥ 94 i Performance og 100 i de andre kategoriene på alle sider (se
+ `docs/ytelse.md`).
+- **Deploy:** `deploy/setup-vm.sh`, systemd-enhet og Apache-konfig for `it2810-17.idi.ntnu.no`,
+ beskrevet i `docs/deploy.md`.
+
+## Hva gjenstår / hva Nicolay må gjøre
+
+1. **Deploy på VM-en:** logg inn og kjør `bash deploy/setup-vm.sh`. Skriptet laster ned de ekte
+ IMDb-filene, noe utviklingsmiljøet ikke fikk lov til (se `BLOCKERS.md`).
+2. **Sjekk med ekte data:** søk, filtre og ytelse er målt på et syntetisk datasett på 120 000
+ titler. Det ekte datasettet får omtrent like mange titler etter filteret (≥ 100 stemmer), men
+ tallene i `docs/ytelse.md` bør måles på nytt på VM-en.
+3. **GitLab:** repoet ligger på GitHub. Skal det inn på NTNU GitLab, pusher du det dit, og
+ `.gitlab-ci.yml` kjører lint, typecheck og enhetstester. Issues og MR-er er ikke opprettet
+ (se `docs/beslutninger.md`).
+4. **README.md** skrives av gruppa, jf. CLAUDE.md. Fakta og installasjonssteg ligger i
+ `docs/oppsett.md` og `docs/deploy.md`.
+
+## Kjente begrensninger
+
+- Søk folder ikke aksenter: «cafe» finner ikke «Café». Store og små bokstaver er dekket.
+- Søk på 1–2 tegn kan ikke bruke trigram-indeksen og er tregere (se `docs/ytelse.md`).
+- Anonym bruker-ID ligger i `localStorage`. Anmeldelser og liste følger nettleseren, ikke en
+ innlogget bruker.
diff --git a/docs/ytelse.md b/docs/ytelse.md
index 2acaf00..ef575e4 100644
--- a/docs/ytelse.md
+++ b/docs/ytelse.md
@@ -84,3 +84,24 @@ Execution Time: 8.949 ms
`npm run db:seed -w backend` importerer 120 000 syntetiske titler (162 000 linjer i `basics`) på ca.
17 s, inkludert oppdatering av alle indekser. Fila strømmes (gunzip + readline) og skrives i
batcher på 2 000 rader; bare rating-rader over stemmegrensen holdes i minnet.
+
+## Lighthouse
+
+Målt 2026-09-30 med Lighthouse (Chromium, headless) mot produksjonsbygget servert av `vite preview`
+og backend med det syntetiske datasettet (120 000 titler). Format: Performance / Accessibility /
+Best Practices / SEO.
+
+| Side | Mobil | Desktop |
+| --------------------------- | -------------- | --------------- |
+| `/project2/` | 94/100/100/100 | 97/100/100/100 |
+| `/project2/title/tt0468569` | 96/100/100/100 | 100/100/100/100 |
+| `/project2/my-list` | 96/100/100/100 | 100/100/100/100 |
+
+- `vite preview` komprimerer ikke. Med gzip (som Apache-konfigen i `deploy/` slår på) ble forsiden
+ 97 på mobil og 99 på desktop.
+- Performance varierer 1–3 poeng mellom kjøringer, mest på forsiden på mobil (Total Blocking Time).
+- Før fiksen lå Performance på 72–79. Årsaken var layoutskift: footeren startet midt i
+ viewporten og ble skjøvet ned når innholdet kom, og rullefeltet flyttet siden sideveis. Løst med
+ `#root` som flex-kolonne med `min-height: 100vh` og `scrollbar-gutter: stable`.
+- Initial JS er ca. 166 kB gzip (React, Apollo Client med rxjs, React Router). Detaljside og
+ «Min liste» lazy-lastes. Videre kutt ville krevd å bytte ut Apollo, som er fastsatt i CLAUDE.md.
diff --git a/e2e/flow.spec.ts b/e2e/flow.spec.ts
index dc59e3f..e062a9c 100644
--- a/e2e/flow.spec.ts
+++ b/e2e/flow.spec.ts
@@ -107,3 +107,18 @@ test('tastatur: skip-link flytter fokus til hovedinnholdet', async ({ page }) =>
await page.keyboard.press('Enter');
await expect(page.locator('main')).toBeFocused();
});
+
+test('tilbake fra detaljsiden bevarer scroll-posisjonen', async ({ page }) => {
+ await page.goto('./?q=dark');
+ const cards = page.locator('main article');
+ await expect(cards.nth(10)).toBeVisible();
+ await cards.nth(10).scrollIntoViewIfNeeded();
+ const before = await page.evaluate(() => window.scrollY);
+ expect(before).toBeGreaterThan(200);
+
+ await cards.nth(10).getByRole('link').click();
+ await expect(page).toHaveURL(/\/title\//);
+ await page.goBack();
+ await expect(page).toHaveURL(/q=dark/);
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(before - 50);
+});
From bf3a7de342f9d41b9f3636aace94423db4eba528 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:52:17 +0000
Subject: [PATCH 12/29] fix(deploy): fail setup when the API does not respond
---
deploy/setup-vm.sh | 24 +++++++++++++++++++++---
1 file changed, 21 insertions(+), 3 deletions(-)
diff --git a/deploy/setup-vm.sh b/deploy/setup-vm.sh
index a9930a3..ddc683c 100755
--- a/deploy/setup-vm.sh
+++ b/deploy/setup-vm.sh
@@ -81,7 +81,25 @@ sudo apachectl configtest
sudo systemctl reload apache2
step "Sjekker at alt svarer"
-sleep 2
-curl -fsS -X POST http://localhost/project2/graphql -H 'content-type: application/json' \
- -d '{"query":"{ search(first: 1) { totalCount } }"}' && echo
+# Backenden trenger et par sekunder på å starte; prøv noen ganger før vi gir opp.
+check() {
+ curl -fsS -m 5 -X POST "$1" -H 'content-type: application/json' \
+ -d '{"query":"{ search(first: 1) { totalCount } }"}'
+}
+for i in 1 2 3 4 5 6 7 8 9 10; do
+ check http://127.0.0.1:3001/graphql >/dev/null 2>&1 && break
+ sleep 2
+done
+# `if !` i stedet for `cmd && echo`: set -e overser feil i &&-lister, og da ville skriptet
+# meldt «Ferdig!» selv om API-et ikke svarte.
+if ! check http://127.0.0.1:3001/graphql; then
+ echo "FEIL: backenden svarer ikke på port 3001. Se: sudo journalctl -u project2-backend -n 50" >&2
+ exit 1
+fi
+echo
+if ! check http://localhost/project2/graphql; then
+ echo "FEIL: backenden svarer, men ikke via Apache. Se: sudo tail -n 50 /var/log/apache2/error.log" >&2
+ exit 1
+fi
+echo
echo "Ferdig! Åpne http://it2810-17.idi.ntnu.no/project2/"
From 5e6e16ae1b68bf64d9970a8c4e1026d140bf4457 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 08:54:44 +0000
Subject: [PATCH 13/29] chore(deploy): add VM diagnostics script
---
deploy/sjekk.sh | 24 ++++++++++++++++++++++++
1 file changed, 24 insertions(+)
create mode 100755 deploy/sjekk.sh
diff --git a/deploy/sjekk.sh b/deploy/sjekk.sh
new file mode 100755
index 0000000..13d928d
--- /dev/null
+++ b/deploy/sjekk.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# Feilsøking på VM-en: kjører alle sjekkene og skriver resultatet samlet, så det kan limes inn
+# i én melding. Endrer ingenting.
+Q='{"query":"{ search(first: 1) { totalCount } }"}'
+line() { printf '\n===== %s =====\n' "$1"; }
+
+line "1. Backend direkte (port 3001)"
+curl -sS -m 10 -X POST http://127.0.0.1:3001/graphql -H 'content-type: application/json' -d "$Q"
+
+line "2. Via Apache (/project2/graphql)"
+curl -sS -m 10 -i -X POST http://localhost/project2/graphql -H 'content-type: application/json' -d "$Q" | head -n 20
+
+line "3. Forsiden via Apache"
+curl -sS -m 10 -o /dev/null -w 'HTTP %{http_code}\n' http://localhost/project2/
+
+line "4. Apache-moduler og konfig"
+apache2ctl -M 2>/dev/null | grep -E 'proxy|rewrite|headers|deflate'
+ls /etc/apache2/conf-enabled/ /etc/apache2/sites-enabled/
+
+line "5. Apache-feillogg (siste 20)"
+sudo tail -n 20 /var/log/apache2/error.log
+
+line "6. Backend-logg (siste 20)"
+sudo journalctl -u project2-backend -n 20 --no-pager -o cat
From 06e7568282b3700949291e42cf9cbc1905f2fe58 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 09:02:08 +0000
Subject: [PATCH 14/29] fix(frontend): generate user id without
crypto.randomUUID on plain http
---
deploy/sjekk.sh | 11 +++++++++++
docs/beslutninger.md | 5 +++++
docs/ki-logg.md | 1 +
frontend/src/apollo/client.test.ts | 15 ++++++++++++++-
frontend/src/apollo/userId.ts | 16 ++++++++++++++--
5 files changed, 45 insertions(+), 3 deletions(-)
diff --git a/deploy/sjekk.sh b/deploy/sjekk.sh
index 13d928d..38577ee 100755
--- a/deploy/sjekk.sh
+++ b/deploy/sjekk.sh
@@ -10,6 +10,17 @@ curl -sS -m 10 -X POST http://127.0.0.1:3001/graphql -H 'content-type: applicati
line "2. Via Apache (/project2/graphql)"
curl -sS -m 10 -i -X POST http://localhost/project2/graphql -H 'content-type: application/json' -d "$Q" | head -n 20
+line "7. Samme spørringer som forsiden sender (via Apache)"
+UID_HDR='x-user-id: 00000000-0000-4000-8000-000000000000'
+curl -sS -m 20 -w '\n(HTTP %{http_code}, %{time_total}s)\n' -X POST http://localhost/project2/graphql \
+ -H 'content-type: application/json' -H "$UID_HDR" \
+ -d '{"query":"query Search($first:Int,$sort:SortInput){ search(first:$first, sort:$sort){ totalCount pageInfo{hasNextPage endCursor} edges{ cursor node{ id primaryTitle type startYear genres averageRating numVotes } } } }","variables":{"first":20,"sort":{"field":"RELEVANCE","direction":"DESC"}}}' | head -c 600
+echo
+curl -sS -m 20 -w '\n(HTTP %{http_code}, %{time_total}s)\n' -X POST http://localhost/project2/graphql \
+ -H 'content-type: application/json' -H "$UID_HDR" \
+ -d '{"query":"{ facets { genres{value count} decades{value count} types{value count} } genres }"}' | head -c 600
+echo
+
line "3. Forsiden via Apache"
curl -sS -m 10 -o /dev/null -w 'HTTP %{http_code}\n' http://localhost/project2/
diff --git a/docs/beslutninger.md b/docs/beslutninger.md
index 8bbf54e..464920d 100644
--- a/docs/beslutninger.md
+++ b/docs/beslutninger.md
@@ -72,3 +72,8 @@ Valg agentene har tatt uten å spørre, med begrunnelse. Nyeste nederst.
snitt og antall). «Min liste» bruker `cache-and-network` og fjerner rader lokalt i cachen ved «Fjern».
- **Frontend: ingen nye biblioteker.** Bundle ca. 166 kB gzip initielt (React + Apollo + React Router);
detalj- og listesiden er lazy (ca. 4 kB gzip til sammen).
+- **Bruker-ID uten `crypto.randomUUID`.** VM-en serverer appen over http, som ikke er en sikker
+ kontekst, og der finnes ikke `crypto.randomUUID`. Alle GraphQL-requests feilet derfor i
+ produksjon, selv om alle tester (som kjører på `localhost`, som regnes som sikker) var grønne.
+ UUID v4 lages nå med `crypto.getRandomValues`, som finnes overalt. Verifisert i Chromium mot en
+ ikke-localhost http-adresse.
diff --git a/docs/ki-logg.md b/docs/ki-logg.md
index fb15970..d9a986b 100644
--- a/docs/ki-logg.md
+++ b/docs/ki-logg.md
@@ -11,3 +11,4 @@
- 2026-09-30 – M4 Playwright E2E + axe + 320 px + scroll-bevaring – leder – 32 E2E-tester grønne.
- 2026-09-30 – M4 ren klon (`npm ci`): lint, typecheck, test og build grønne – leder.
- 2026-09-30 – M5 deploy-skript, systemd og Apache – leder – ikke kjørt mot VM (krever Nicolay).
+- 2026-09-30 – Fiks: appen feilet på VM-en (http) fordi crypto.randomUUID mangler utenfor sikre kontekster – leder – regresjonstest + verifisert i nettleser over http.
diff --git a/frontend/src/apollo/client.test.ts b/frontend/src/apollo/client.test.ts
index caad21a..b480f65 100644
--- a/frontend/src/apollo/client.test.ts
+++ b/frontend/src/apollo/client.test.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GENRES_QUERY } from '../graphql/operations';
import { createClient } from './client';
-import { getUserId } from './userId';
+import { getUserId, uuidV4 } from './userId';
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
@@ -28,6 +28,19 @@ describe('anonym bruker-id', () => {
expect(getUserId()).toBe(id);
spy.mockRestore();
});
+
+ // Regresjon: VM-en serverer over http, der crypto.randomUUID ikke finnes (bare i sikre kontekster).
+ it('virker uten crypto.randomUUID (http, ikke sikker kontekst)', () => {
+ const spy = vi.spyOn(crypto, 'randomUUID').mockImplementation(() => {
+ throw new TypeError('crypto.randomUUID is not a function');
+ });
+ const ids = new Set(Array.from({ length: 50 }, () => uuidV4()));
+ expect(ids.size).toBe(50);
+ for (const id of ids) expect(id).toMatch(UUID_V4);
+ expect(getUserId()).toMatch(UUID_V4);
+ expect(spy).not.toHaveBeenCalled();
+ spy.mockRestore();
+ });
});
describe('Apollo-klient', () => {
diff --git a/frontend/src/apollo/userId.ts b/frontend/src/apollo/userId.ts
index 71dc275..14d6731 100644
--- a/frontend/src/apollo/userId.ts
+++ b/frontend/src/apollo/userId.ts
@@ -4,6 +4,18 @@ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f
let memoryFallback: string | null = null;
+/**
+ * UUID v4 uten crypto.randomUUID: den finnes bare i sikre kontekster (https/localhost), og VM-en
+ * serverer appen over vanlig http. getRandomValues er tilgjengelig overalt.
+ */
+export function uuidV4(): string {
+ const b = crypto.getRandomValues(new Uint8Array(16));
+ b[6] = (b[6]! & 0x0f) | 0x40; // versjon 4
+ b[8] = (b[8]! & 0x3f) | 0x80; // variant 10xx
+ const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
+}
+
/**
* Anonym bruker-id. Lagres i localStorage slik at «Min liste» og egne
* anmeldelser overlever omlasting. Faller tilbake til minnet hvis lagring
@@ -13,11 +25,11 @@ export function getUserId(): string {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && UUID_V4.test(stored)) return stored;
- const id = crypto.randomUUID();
+ const id = uuidV4();
localStorage.setItem(STORAGE_KEY, id);
return id;
} catch {
- memoryFallback ??= crypto.randomUUID();
+ memoryFallback ??= uuidV4();
return memoryFallback;
}
}
From cd372be1ffff062d65bb01058ec1fbb2e200557a Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 09:04:34 +0000
Subject: [PATCH 15/29] docs: record successful deployment to the VM
---
BLOCKERS.md | 2 +-
docs/status.md | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/BLOCKERS.md b/BLOCKERS.md
index 169d645..2b4b22c 100644
--- a/BLOCKERS.md
+++ b/BLOCKERS.md
@@ -2,7 +2,7 @@
Punkter her kan ikke løses av agentene alene. Hvert punkt sier hva som trengs.
-- **IMDb-datasettet kunne ikke lastes ned i utviklingsmiljøet.** Sky-containeren agentene kjørte i har
+- ~~**IMDb-datasettet kunne ikke lastes ned i utviklingsmiljøet.**~~ Løst: importert på VM-en av Nicolay 2026-09-30. Sky-containeren agentene kjørte i har
en nettverkspolicy som blokkerer `datasets.imdbws.com` (HTTP 403 fra proxyen). Importskriptet er
skrevet for de ekte filene og testet mot et syntetisk datasett i samme format
(`npm run db:fixture -w backend`). Last ned de ekte filene selv og kjør importen, se `docs/oppsett.md`.
diff --git a/docs/status.md b/docs/status.md
index 17e1871..bae0374 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -24,11 +24,11 @@ Alle punktene i `PLAN.md` er gjort og verifisert i utviklingsmiljøet.
## Hva gjenstår / hva Nicolay må gjøre
-1. **Deploy på VM-en:** logg inn og kjør `bash deploy/setup-vm.sh`. Skriptet laster ned de ekte
- IMDb-filene, noe utviklingsmiljøet ikke fikk lov til (se `BLOCKERS.md`).
-2. **Sjekk med ekte data:** søk, filtre og ytelse er målt på et syntetisk datasett på 120 000
- titler. Det ekte datasettet får omtrent like mange titler etter filteret (≥ 100 stemmer), men
- tallene i `docs/ytelse.md` bør måles på nytt på VM-en.
+1. **Deploy på VM-en: gjort 2026-09-30.** Appen kjører på
+ med det ekte IMDb-datasettet (190 607 titler med ≥ 100 stemmer). Ved oppdateringer: `git pull` og
+ `bash deploy/setup-vm.sh` på VM-en. Feilsøking: `bash deploy/sjekk.sh`.
+2. **Mål ytelse på nytt med ekte data:** tallene i `docs/ytelse.md` er fra et syntetisk datasett
+ på 120 000 titler. Kjør Lighthouse mot VM-adressen og `EXPLAIN ANALYZE` der.
3. **GitLab:** repoet ligger på GitHub. Skal det inn på NTNU GitLab, pusher du det dit, og
`.gitlab-ci.yml` kjører lint, typecheck og enhetstester. Issues og MR-er er ikke opprettet
(se `docs/beslutninger.md`).
From 37960dbf6632b9d43e6255be0f499a5bbcccb1db Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 09:23:53 +0000
Subject: [PATCH 16/29] fix(deploy): add security headers to Apache config
---
deploy/apache-project2.conf | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/deploy/apache-project2.conf b/deploy/apache-project2.conf
index cddb606..5fdcc44 100644
--- a/deploy/apache-project2.conf
+++ b/deploy/apache-project2.conf
@@ -24,6 +24,16 @@ Alias /project2 /var/www/html/project2
RewriteRule ^ index.html [L]
+# Sikkerhetsheadere for hele appen. Ingen av dem påvirker funksjonaliteten: siden skal aldri
+# rammes inn av andre sider (clickjacking), MIME-typer skal ikke gjettes, og adressen med søket
+# skal ikke lekke til andre nettsteder.
+
+ Header always set X-Frame-Options "DENY"
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+ Header always set Cross-Origin-Opener-Policy "same-origin"
+
+
# Komprimer tekstressurser – store besparelser på JS/CSS/JSON for lite CPU.
AddOutputFilterByType DEFLATE text/html text/css text/plain application/javascript application/json image/svg+xml
From ba1d689969e0f84f56305503bd08c01a1fdbc231 Mon Sep 17 00:00:00 2001
From: Nicolay Kopaas
Date: Wed, 30 Sep 2026 11:32:08 +0000
Subject: [PATCH 17/29] feat: add TMDB artwork and cinematic UI (backend tests
pending)
---
.env.example | 7 +
backend/migrations/002_title_artwork.sql | 12 +
backend/package.json | 4 +-
backend/scripts/prefetch-artwork.ts | 108 +++
backend/scripts/tmdb-mock.ts | 225 +++++
backend/src/app.ts | 9 +-
backend/src/artwork.ts | 324 ++++++++
backend/src/config.ts | 4 +
backend/src/context.ts | 7 +-
backend/src/resolvers/index.ts | 17 +
backend/src/resolvers/mappers.ts | 2 +-
backend/src/typeDefs.ts | 5 +
backend/test/artwork.test.ts | 401 +++++++++
backend/test/helpers.ts | 3 +
deploy/setup-vm.sh | 21 +
docs/api.md | 19 +
docs/beslutninger.md | 39 +
docs/oppsett.md | 30 +
e2e/a11y.spec.ts | 15 +-
e2e/flow.spec.ts | 100 ++-
frontend/index.html | 3 +-
frontend/src/components/HeaderSearch.tsx | 29 +
frontend/src/components/Hero.tsx | 77 ++
frontend/src/components/Layout.test.tsx | 93 +++
frontend/src/components/Layout.tsx | 27 +-
frontend/src/components/LazyRow.tsx | 32 +
frontend/src/components/ListToggleButton.tsx | 6 +-
frontend/src/components/PosterCard.test.tsx | 104 +++
frontend/src/components/PosterCard.tsx | 95 +++
frontend/src/components/PosterSkeleton.tsx | 20 +
frontend/src/components/ResultSkeleton.tsx | 13 -
.../src/components/SearchResults.test.tsx | 2 +-
frontend/src/components/SearchResults.tsx | 10 +-
frontend/src/components/TitleCard.tsx | 44 -
frontend/src/components/TitleRow.test.tsx | 112 +++
frontend/src/components/TitleRow.tsx | 123 +++
frontend/src/graphql/operations.ts | 40 +
frontend/src/graphql/types.ts | 19 +
frontend/src/hooks/useHeaderOverlay.ts | 14 +
frontend/src/hooks/useNearViewport.ts | 29 +
frontend/src/hooks/useScrolled.ts | 13 +
frontend/src/lib/browseRows.test.ts | 29 +
frontend/src/lib/browseRows.ts | 46 +
frontend/src/lib/format.test.ts | 28 +
frontend/src/lib/format.ts | 15 +
frontend/src/lib/searchState.ts | 7 +
frontend/src/pages/HomePage.test.tsx | 166 +++-
frontend/src/pages/HomePage.tsx | 64 +-
frontend/src/pages/MyListPage.test.tsx | 17 +
frontend/src/pages/MyListPage.tsx | 16 +-
frontend/src/pages/NotFoundPage.tsx | 4 +-
frontend/src/pages/TitlePage.test.tsx | 54 +-
frontend/src/pages/TitlePage.tsx | 225 +++--
frontend/src/styles/global.css | 783 ++++++++++++++----
frontend/src/test/utils.tsx | 29 +-
playwright.config.ts | 18 +-
56 files changed, 3408 insertions(+), 350 deletions(-)
create mode 100644 backend/migrations/002_title_artwork.sql
create mode 100644 backend/scripts/prefetch-artwork.ts
create mode 100644 backend/scripts/tmdb-mock.ts
create mode 100644 backend/src/artwork.ts
create mode 100644 backend/test/artwork.test.ts
create mode 100644 frontend/src/components/HeaderSearch.tsx
create mode 100644 frontend/src/components/Hero.tsx
create mode 100644 frontend/src/components/Layout.test.tsx
create mode 100644 frontend/src/components/LazyRow.tsx
create mode 100644 frontend/src/components/PosterCard.test.tsx
create mode 100644 frontend/src/components/PosterCard.tsx
create mode 100644 frontend/src/components/PosterSkeleton.tsx
delete mode 100644 frontend/src/components/ResultSkeleton.tsx
delete mode 100644 frontend/src/components/TitleCard.tsx
create mode 100644 frontend/src/components/TitleRow.test.tsx
create mode 100644 frontend/src/components/TitleRow.tsx
create mode 100644 frontend/src/hooks/useHeaderOverlay.ts
create mode 100644 frontend/src/hooks/useNearViewport.ts
create mode 100644 frontend/src/hooks/useScrolled.ts
create mode 100644 frontend/src/lib/browseRows.test.ts
create mode 100644 frontend/src/lib/browseRows.ts
create mode 100644 frontend/src/lib/format.test.ts
diff --git a/.env.example b/.env.example
index eb463fb..d3da4d3 100644
--- a/.env.example
+++ b/.env.example
@@ -15,3 +15,10 @@ IMDB_MIN_VOTES=100
# Frontend (valgfri – standard er /graphql)
VITE_GRAPHQL_URL=
+
+# Bilder og handling fra TMDB (https://www.themoviedb.org/settings/api). Uten nøkkel vises
+# plassholdere i stedet for plakater.
+TMDB_API_KEY=
+# Overstyres i utvikling/E2E for å bruke den lokale falske TMDB-serveren (npm run tmdb:mock -w backend):
+# TMDB_API_URL=http://localhost:3999/3
+# TMDB_IMAGE_URL=http://localhost:3999/t/p
diff --git a/backend/migrations/002_title_artwork.sql b/backend/migrations/002_title_artwork.sql
new file mode 100644
index 0000000..7fc07f1
--- /dev/null
+++ b/backend/migrations/002_title_artwork.sql
@@ -0,0 +1,12 @@
+-- Bilder og handling fra TMDB, hentet ved første behov og lagret her (se docs/beslutninger.md).
+-- Egen tabell i stedet for kolonner på titles: titles er en ren IMDb-kopi som kan importeres
+-- på nytt, mens dette er hentet fra en ekstern tjeneste med egen levetid.
+CREATE TABLE title_artwork (
+ title_id text PRIMARY KEY REFERENCES titles (id) ON DELETE CASCADE,
+ -- 'missing' huskes slik at titler TMDB ikke kjenner ikke slås opp igjen ved hvert søk.
+ status text NOT NULL CHECK (status IN ('found', 'missing')),
+ poster_path text,
+ backdrop_path text,
+ overview text,
+ fetched_at timestamptz NOT NULL DEFAULT now()
+);
diff --git a/backend/package.json b/backend/package.json
index d930e86..645902f 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -12,7 +12,9 @@
"test": "vitest run",
"db:migrate": "tsx scripts/migrate.ts",
"db:seed": "tsx scripts/import-imdb.ts",
- "db:fixture": "tsx scripts/generate-fixture.ts"
+ "db:fixture": "tsx scripts/generate-fixture.ts",
+ "db:artwork": "tsx scripts/prefetch-artwork.ts",
+ "tmdb:mock": "tsx scripts/tmdb-mock.ts"
},
"devDependencies": {
"@types/node": "^26.6.3",
diff --git a/backend/scripts/prefetch-artwork.ts b/backend/scripts/prefetch-artwork.ts
new file mode 100644
index 0000000..964a465
--- /dev/null
+++ b/backend/scripts/prefetch-artwork.ts
@@ -0,0 +1,108 @@
+import { pathToFileURL } from 'node:url';
+import { ArtworkService, type TitleKind } from '../src/artwork.js';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+
+/**
+ * Forhåndshenter bilder for de mest populære titlene, slik at de første søkene i en demo ikke
+ * må vente på TMDB. Trygt å avbryte (Ctrl+C) og kjøre på nytt: titler som allerede har rad
+ * i title_artwork hoppes over.
+ */
+
+export interface PrefetchSummary {
+ found: number;
+ missing: number;
+ errors: number;
+ stoppedBy: 'done' | 'signal' | 'rejected-key';
+}
+
+export async function prefetchArtwork(
+ pool: Pool,
+ service: ArtworkService,
+ opts: {
+ limit: number;
+ ratePerSecond?: number;
+ signal?: { aborted: boolean };
+ log?: (msg: string) => void;
+ },
+): Promise {
+ const log = opts.log ?? (() => {});
+ const interval = 1000 / (opts.ratePerSecond ?? 30);
+ const { rows } = await pool.query<{ id: string; title_type: 'movie' | 'series' }>(
+ `SELECT t.id, t.title_type FROM titles t
+ WHERE NOT EXISTS (SELECT 1 FROM title_artwork a WHERE a.title_id = t.id)
+ ORDER BY t.num_votes DESC, t.id
+ LIMIT $1`,
+ [opts.limit],
+ );
+ log(`${rows.length} titler å hente.`);
+
+ const summary: PrefetchSummary = { found: 0, missing: 0, errors: 0, stoppedBy: 'done' };
+ const running = new Set>();
+ let nextStart = Date.now();
+ let done = 0;
+
+ for (const row of rows) {
+ if (opts.signal?.aborted) {
+ summary.stoppedBy = 'signal';
+ break;
+ }
+ if (!service.enabled) {
+ summary.stoppedBy = 'rejected-key';
+ break;
+ }
+ // Jevn takt i stedet for salver på 30: TMDB teller per sekund.
+ const wait = nextStart - Date.now();
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
+ nextStart = Math.max(nextStart, Date.now()) + interval;
+
+ const kind: TitleKind = row.title_type === 'movie' ? 'MOVIE' : 'SERIES';
+ const p = service.lookup(row.id, kind).then((o) => {
+ if (o.kind === 'found') summary.found++;
+ else if (o.kind === 'missing') summary.missing++;
+ else if (o.kind === 'error') summary.errors++;
+ if (++done % 100 === 0) {
+ log(
+ `${done}/${rows.length} (funnet ${summary.found}, ukjent ${summary.missing}, feil ${summary.errors})`,
+ );
+ }
+ });
+ running.add(p);
+ void p.finally(() => running.delete(p));
+ // Ikke la køen av ventende oppslag vokse ubegrenset hvis TMDB er treg.
+ if (running.size >= 64) await Promise.race(running);
+ }
+ await Promise.all(running);
+ return summary;
+}
+
+async function main() {
+ if (!config.tmdbApiKey) {
+ throw new Error('TMDB_API_KEY er ikke satt (se docs/oppsett.md).');
+ }
+ const arg = process.argv[2] ?? process.env.ARTWORK_LIMIT;
+ const limit = Number.parseInt(arg ?? '', 10) || 2000;
+ const pool = createPool(config.databaseUrl);
+ const service = new ArtworkService({ pool, apiKey: config.tmdbApiKey, disableMs: 3_600_000 });
+ const signal = { aborted: false };
+ process.on('SIGINT', () => {
+ console.log('\nAvbryter etter pågående oppslag …');
+ signal.aborted = true;
+ });
+ try {
+ const s = await prefetchArtwork(pool, service, { limit, signal, log: console.log });
+ console.log(
+ `Ferdig (${s.stoppedBy}): ${s.found} funnet, ${s.missing} ukjent hos TMDB, ${s.errors} feilet. Kjør på nytt for å prøve de feilede.`,
+ );
+ if (s.stoppedBy === 'rejected-key') process.exitCode = 1;
+ } finally {
+ await pool.end();
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/scripts/tmdb-mock.ts b/backend/scripts/tmdb-mock.ts
new file mode 100644
index 0000000..768db54
--- /dev/null
+++ b/backend/scripts/tmdb-mock.ts
@@ -0,0 +1,225 @@
+import { createServer, type Server } from 'node:http';
+import type { AddressInfo } from 'node:net';
+import { pathToFileURL } from 'node:url';
+import { config } from '../src/config.js';
+import { createPool, type Pool } from '../src/db.js';
+
+/**
+ * Falsk TMDB for utvikling, E2E og tester: internett er ikke alltid tilgjengelig, og vi vil
+ * ikke bruke en ekte nøkkel (eller bryte TMDBs vilkår) i CI. Svarer med samme form som TMDB,
+ * og tegner plakater som SVG på sparket.
+ */
+
+export interface MockState {
+ /** Forsinkelse før /find svarer (for å teste tidsgrenser). */
+ delayMs: number;
+ /** Tving denne HTTP-statusen fra /find (f.eks. 500 eller 429). */
+ failStatus: number | null;
+ /** Titler som alltid svarer med tomme lister, i tillegg til hver 10. id. */
+ emptyIds: Set;
+ /** Alle /find-kall (imdb-id), i rekkefølge. */
+ calls: string[];
+}
+
+export interface TmdbMock {
+ port: number;
+ server: Server;
+ state: MockState;
+ close: () => Promise;
+}
+
+const idNumber = (id: string) => Number.parseInt(id.replace(/\D/g, ''), 10) || 0;
+
+// Kontrolltegn er ugyldige i XML 1.0 og ville gjort hele SVG-en ulesbar.
+const esc = (s: string) =>
+ [...s]
+ .filter((c) => c.charCodeAt(0) >= 32)
+ .join('')
+ .replace(/[&<>"']/g, (c) => `${c.charCodeAt(0)};`);
+
+function hue(id: string): number {
+ // Litt spredning av tett nummererte id-er, ellers får nabotitler nesten lik farge.
+ return (idNumber(id) * 137.508) % 360;
+}
+
+/** Bryter tittelen i linjer på ca. `max` tegn uten å splitte ord (lange ord kuttes ikke). */
+function wrap(text: string, max: number): string[] {
+ const lines: string[] = [];
+ let line = '';
+ for (const word of text.split(/\s+/).filter(Boolean)) {
+ if (line && (line + ' ' + word).length > max) {
+ lines.push(line);
+ line = word;
+ } else line = line ? `${line} ${word}` : word;
+ }
+ if (line) lines.push(line);
+ return lines.slice(0, 5);
+}
+
+export function renderSvg(id: string, title: string, backdrop: boolean): string {
+ const h = hue(id);
+ const [w, hgt] = backdrop ? [320, 180] : [200, 300];
+ const lines = wrap(title, backdrop ? 26 : 12);
+ const size = backdrop ? 22 : 24;
+ const startY = hgt / 2 - ((lines.length - 1) * size * 1.2) / 2;
+ const text = lines
+ .map((l, i) => `${esc(l)}`)
+ .join('');
+ return ``;
+}
+
+const ADJECTIVES = ['quiet', 'reckless', 'unlikely', 'haunted', 'restless', 'ambitious', 'faded'];
+const NOUNS = ['detective', 'family', 'astronaut', 'musician', 'stranger', 'chef', 'soldier'];
+const GOALS = [
+ 'uncover a long-buried secret',
+ 'save a failing town',
+ 'outrun a past mistake',
+ 'win one last impossible game',
+ 'find their way home',
+];
+
+function overviewFor(id: string): string {
+ const n = idNumber(id);
+ return `A ${ADJECTIVES[n % ADJECTIVES.length]} ${NOUNS[(n >> 3) % NOUNS.length]} sets out to ${GOALS[(n >> 5) % GOALS.length]}.`;
+}
+
+export interface MockOptions {
+ /** Brukes til å slå opp tittelen som tegnes på plakaten. Uten den brukes id-en. */
+ pool?: Pool;
+ delayMs?: number;
+}
+
+export async function startTmdbMock(port = 0, opts: MockOptions = {}): Promise {
+ const state: MockState = {
+ delayMs: opts.delayMs ?? 0,
+ failStatus: null,
+ emptyIds: new Set(),
+ calls: [],
+ };
+ const titleCache = new Map();
+ const titleOf = async (id: string): Promise => {
+ const hit = titleCache.get(id);
+ if (hit) return hit;
+ let title = id;
+ if (opts.pool) {
+ try {
+ const { rows } = await opts.pool.query<{ primary_title: string }>(
+ 'SELECT primary_title FROM titles WHERE id = $1',
+ [id],
+ );
+ title = rows[0]?.primary_title ?? id;
+ } catch {
+ // Uten tittel duger id-en; plakaten er bare pynt.
+ }
+ }
+ if (titleCache.size > 5000) titleCache.clear();
+ titleCache.set(id, title);
+ return title;
+ };
+
+ const server = createServer((req, res) => {
+ void handle(req.url ?? '/', req.headers, res).catch(() => {
+ if (!res.headersSent) res.writeHead(500);
+ res.end();
+ });
+ });
+
+ async function handle(
+ rawUrl: string,
+ headers: import('node:http').IncomingHttpHeaders,
+ res: import('node:http').ServerResponse,
+ ) {
+ const url = new URL(rawUrl, 'http://localhost');
+ const json = (status: number, body: unknown) => {
+ res.writeHead(status, { 'content-type': 'application/json' });
+ res.end(JSON.stringify(body));
+ };
+
+ const find = /^\/3\/find\/([^/]+)$/.exec(url.pathname);
+ if (find) {
+ const id = decodeURIComponent(find[1]!);
+ state.calls.push(id);
+ const authed =
+ url.searchParams.get('api_key') || /^Bearer .+/.test(headers.authorization ?? '');
+ if (!authed) return json(401, { status_code: 7, status_message: 'Invalid API key.' });
+ if (state.delayMs > 0) await new Promise((r) => setTimeout(r, state.delayMs));
+ if (state.failStatus) return json(state.failStatus, { status_message: 'Mock failure' });
+ const n = idNumber(id);
+ // Hver 10. id er ukjent for TMDB, slik at «missing»-stien testes i praksis.
+ if (n % 10 === 0 || state.emptyIds.has(id)) {
+ return json(200, { movie_results: [], tv_results: [] });
+ }
+ const result = {
+ id: n,
+ poster_path: `/${id}.svg`,
+ backdrop_path: `/${id}-bg.svg`,
+ overview: overviewFor(id),
+ };
+ // Annenhver id havner i tv_results uavhengig av IMDb-typen, så fallback-logikken testes.
+ return json(
+ 200,
+ n % 2 === 0
+ ? { movie_results: [result], tv_results: [] }
+ : { movie_results: [], tv_results: [result] },
+ );
+ }
+
+ const img = /^\/t\/p\/w\d+\/([A-Za-z0-9._-]+)\.svg$/.exec(url.pathname);
+ if (img) {
+ const name = img[1]!;
+ const backdrop = name.endsWith('-bg');
+ const id = backdrop ? name.slice(0, -3) : name;
+ const svg = renderSvg(id, await titleOf(id), backdrop);
+ res.writeHead(200, {
+ 'content-type': 'image/svg+xml',
+ 'cache-control': 'public, max-age=86400',
+ 'access-control-allow-origin': '*',
+ });
+ return res.end(svg);
+ }
+
+ json(404, { status_message: 'Not found' });
+ }
+
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(port, resolve);
+ });
+ const actualPort = (server.address() as AddressInfo).port;
+ return {
+ port: actualPort,
+ server,
+ state,
+ close: () =>
+ new Promise((resolve) => {
+ server.closeAllConnections();
+ server.close(() => resolve());
+ }),
+ };
+}
+
+async function main() {
+ const port = Number.parseInt(process.env.TMDB_MOCK_PORT ?? '', 10) || 3999;
+ const delayMs = Number.parseInt(process.env.TMDB_MOCK_DELAY_MS ?? '', 10) || 0;
+ const pool = createPool(config.databaseUrl);
+ const mock = await startTmdbMock(port, { pool, delayMs });
+ console.log(`Falsk TMDB på http://localhost:${mock.port} (API: /3, bilder: /t/p)`);
+ const stop = () =>
+ void mock
+ .close()
+ .then(() => pool.end())
+ .then(() => process.exit(0));
+ process.on('SIGINT', stop);
+ process.on('SIGTERM', stop);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((err) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 784fab1..db2f99a 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -1,5 +1,7 @@
import { GraphQLError, type ValidationRule } from 'graphql';
import { createYoga, type Plugin } from 'graphql-yoga';
+import { ArtworkService } from './artwork.js';
+import { config } from './config.js';
import { createContext, type Context } from './context.js';
import type { Pool } from './db.js';
import { depthLimit } from './depthLimit.js';
@@ -30,13 +32,16 @@ export interface AppOptions {
production?: boolean;
/** Tillatt CORS-opprinnelse (kun utvikling). false = ingen CORS-headere. */
corsOrigin?: string | false;
+ /** TMDB-tjenesten. Standard: bygges fra miljøvariablene (TMDB_API_KEY osv.). */
+ artwork?: ArtworkService;
}
/**
* Bygger Yoga-appen uten å åpne noen port, slik at testene kan kalle yoga.fetch() direkte.
* Yoga selv er en (req, res)-handler for node:http og en fetch-funksjon i én.
*/
-export function createApp({ pool, production, corsOrigin = false }: AppOptions) {
+export function createApp({ pool, production, corsOrigin = false, artwork }: AppOptions) {
+ const artworkService = artwork ?? new ArtworkService({ pool, apiKey: config.tmdbApiKey });
const isProd = production ?? process.env.NODE_ENV === 'production';
// Egendefinert plugin i stedet for ekstra pakker: envelop lar oss legge til valideringsregler.
@@ -51,7 +56,7 @@ export function createApp({ pool, production, corsOrigin = false }: AppOptions)
schema,
graphqlEndpoint: '/graphql',
plugins: [limits],
- context: ({ request }) => createContext(pool, request),
+ context: ({ request }) => createContext(pool, request, artworkService),
logging: process.env.NODE_ENV !== 'test',
graphiql: !isProd,
landingPage: !isProd,
diff --git a/backend/src/artwork.ts b/backend/src/artwork.ts
new file mode 100644
index 0000000..d9bcb25
--- /dev/null
+++ b/backend/src/artwork.ts
@@ -0,0 +1,324 @@
+import { config } from './config.js';
+import type { Pool } from './db.js';
+import { badInput } from './errors.js';
+import { batchLoader } from './loaders.js';
+
+export interface Artwork {
+ posterPath: string | null;
+ backdropPath: string | null;
+ overview: string | null;
+}
+
+export type TitleKind = 'MOVIE' | 'SERIES';
+
+export const POSTER_WIDTHS = [92, 154, 185, 342, 500, 780] as const;
+export const BACKDROP_WIDTHS = [300, 780, 1280] as const;
+
+// Stien havner i en URL som klienten laster; vi slipper derfor bare gjennom det TMDB faktisk
+// bruker (filnavn med tillatte tegn), slik at en ødelagt eller ondsinnet respons ikke kan gi
+// `../`, skjema eller query-strenger i bilde-URL-en.
+const SAFE_PATH = /^\/[A-Za-z0-9._-]+\.(jpg|jpeg|png|svg)$/;
+
+/**
+ * Snapper til nærmeste tillatte bredde (likt avstand: den største, bedre kvalitet enn skarphet).
+ * Ugyldig bredde (≤ 0 eller ikke heltall) avvises heller enn å gjettes på: det er en klientfeil.
+ */
+export function snapWidth(width: number, allowed: readonly number[]): number {
+ if (!Number.isInteger(width) || width <= 0) {
+ throw badInput('width må være et positivt heltall.');
+ }
+ let best = allowed[0]!;
+ for (const w of allowed) {
+ if (Math.abs(w - width) <= Math.abs(best - width)) best = w;
+ }
+ return best;
+}
+
+export function buildImageUrl(
+ base: string,
+ path: string | null,
+ width: number,
+ allowed: readonly number[],
+): string | null {
+ // Bredden valideres også når path mangler, så feil input gir samme svar uavhengig av data.
+ const w = snapWidth(width, allowed);
+ if (!path || !SAFE_PATH.test(path)) return null;
+ return `${base}/w${w}${path}`;
+}
+
+type Outcome =
+ | { kind: 'found'; artwork: Artwork }
+ | { kind: 'missing' }
+ | { kind: 'error' }
+ | { kind: 'disabled' };
+
+export interface ArtworkOptions {
+ pool: Pool;
+ apiKey?: string | undefined;
+ apiUrl?: string;
+ imageUrl?: string;
+ /** Maks samtidige TMDB-kall totalt i prosessen. */
+ concurrency?: number;
+ /** Tidsgrense per TMDB-kall. */
+ callTimeoutMs?: number;
+ /** Hvor lenge én request maksimalt venter på bilder. */
+ batchDeadlineMs?: number;
+ /** Hvor lenge en feilet tittel hoppes over før vi prøver TMDB igjen. */
+ negativeTtlMs?: number;
+ /** Hvor lenge vi lar TMDB være i fred etter 401/403. */
+ disableMs?: number;
+ fetch?: typeof fetch;
+ log?: (msg: string) => void;
+}
+
+interface TmdbResult {
+ poster_path?: unknown;
+ backdrop_path?: unknown;
+ overview?: unknown;
+}
+
+const str = (v: unknown): string | null => (typeof v === 'string' && v.length > 0 ? v : null);
+
+/**
+ * TMDB-klient med DB-cache. Én instans per prosess (og per test), fordi negativ cache,
+ * «nøkkelen er ugyldig»-tilstand og samtidighetsgrensen skal deles av alle requests.
+ */
+export class ArtworkService {
+ readonly imageUrl: string;
+ private readonly pool: Pool;
+ private readonly apiKey: string | undefined;
+ private readonly apiUrl: string;
+ private readonly concurrency: number;
+ private readonly callTimeoutMs: number;
+ private readonly batchDeadlineMs: number;
+ private readonly negativeTtlMs: number;
+ private readonly disableMs: number;
+ private readonly fetchImpl: typeof fetch;
+ private readonly log: (msg: string) => void;
+
+ private active = 0;
+ private readonly waiting: (() => void)[] = [];
+ /** Samme tittel spurt av flere requests samtidig gir ett TMDB-kall. */
+ private readonly inflight = new Map>();
+ private readonly failedUntil = new Map();
+ private disabledUntil = 0;
+
+ constructor(opts: ArtworkOptions) {
+ this.pool = opts.pool;
+ this.apiKey = opts.apiKey || undefined;
+ this.apiUrl = (opts.apiUrl ?? config.tmdbApiUrl).replace(/\/+$/, '');
+ this.imageUrl = (opts.imageUrl ?? config.tmdbImageUrl).replace(/\/+$/, '');
+ this.concurrency = opts.concurrency ?? 8;
+ this.callTimeoutMs = opts.callTimeoutMs ?? 4000;
+ this.batchDeadlineMs = opts.batchDeadlineMs ?? 2500;
+ this.negativeTtlMs = opts.negativeTtlMs ?? 5 * 60_000;
+ this.disableMs = opts.disableMs ?? 10 * 60_000;
+ this.fetchImpl = opts.fetch ?? fetch;
+ this.log = opts.log ?? ((m) => console.warn(m));
+ }
+
+ get enabled(): boolean {
+ return this.apiKey !== undefined && Date.now() >= this.disabledUntil;
+ }
+
+ /**
+ * Cachede rader fra databasen, og TMDB-oppslag for resten innen tidsgrensen. Titler uten svar
+ * i tide utelates (→ null hos kalleren); oppslaget fullføres og lagres likevel i bakgrunnen.
+ */
+ async resolve(refs: { id: string; kind: TitleKind }[]): Promise