diff --git a/.env.sample b/.env.sample index c127a56..507543d 100644 --- a/.env.sample +++ b/.env.sample @@ -15,6 +15,9 @@ APP_PORT=5173 # TIP: You can use `openssl rand -hex 32` to generate a secure random key. APP_SECRET=YOUR_APP_SECRET_KEY +# Default timezone for the UI and date management +VITE_TIMEZONE=Europe/Paris + # ------------------------------------------------------------------------------ # 2. Email Configuration (App Side) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b486e92 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,283 @@ +# AGENTS.md — StartER (start-express-react) + +> AI agent briefing for this codebase. Read this before writing or modifying any code. +> Human-readable docs live in the [wiki](https://github.com/rocambille/start-express-react/wiki). + +--- + +## Stack + +- **Backend**: Node.js + Express 5, TypeScript, Zod (validation), `node:sqlite` (sync API) +- **Frontend**: React 19, React Router (with SSR/hydration), Vite, Pico CSS +- **Database**: SQLite — zero-config, synchronous, file at `src/database/data/database.sqlite` +- **Tooling**: Biome (lint + format), Vitest (tests), tsx (runtime), Docker (optional) + +--- + +## Directory structure + +``` +. +├── server.ts # Single entry point — bridges Express + Vite +├── index.html # Vite root — contains +├── src/ +│ ├── entry-client.tsx # Client-side hydration (hydrateRoot) +│ ├── entry-server.tsx # SSR rendering (renderToPipeableStream) +│ ├── database/ +│ │ ├── schema.sql # SQLite schema — source of truth for DB structure +│ │ ├── seeder.sql # Test/seed data +│ │ └── data/ +│ │ └── database.sqlite # Generated locally — NOT committed to git +│ ├── express/ +│ │ ├── routes.ts # Registers all Express modules via importAndUse() +│ │ ├── helpers/ # Infrastructure: cache, validation, converters +│ │ └── modules/ # Business modules (item/, user/, auth/, ...) +│ │ └── / +│ │ ├── Routes.ts # Route declarations +│ │ ├── Actions.ts # Request handlers (thin, delegate to repo) +│ │ ├── ParamConverter.ts # Converts URL params to entity-typed objects +│ │ ├── Repository.ts # All SQL queries for this entity +│ │ └── Validator.ts # Zod schema + validate middleware +│ ├── react/ +│ │ ├── routes.tsx # React Router route tree +│ │ ├── helpers/ # Hooks, mutations, fetch utilities +│ │ └── components/ # UI components and pages +│ └── types/ +│ └── index.d.ts # Shared TypeScript types (Item, User, etc.) +├── tests/ +│ └── contracts.ts # API contract definitions — declarative source of truth +└── biome.json # Lint + format config +``` + +--- + +## Common commands + +### Development + +```bash +npm install +cp .env.sample .env +npm run database:sync # Load schema + seed data into SQLite +npm run dev # Start dev server (Express + Vite together on port 5173) +``` + +### Database + +```bash +npm run database:schema:load # Apply schema.sql to the SQLite file +npm run database:seeder:load # Load seeder.sql test data +npm run database:sync # Both above — resets DB to a clean state +npm run database:sync -- -n # Non-interactive (CI/CD — skips confirmation prompt) +npm run database:schema:load -- -n # Same, schema only +npm run database:seeder:load -- -n # Same, seeder only +``` + +> SQLite requires NO Docker, NO connection string, NO async setup. The DB file is created on the fly. + +### Code quality (run before every commit) + +```bash +npm run types:check # TypeScript strict check (tsc --noEmit) +npm run biome:check # Lint + format check +npm run biome:fix # Auto-fix formatting +npx vitest run --exclude tests/install # Run all tests except install checks +``` + +> The pre-commit hook runs `types:check`, `biome:check`, and Vitest automatically. + +### Creating new modules (pattern cloning — preferred over writing from scratch) + +```bash +# Clone an existing module, replacing all name references +npm run make:clone -- + +# Example: create a "post" module from "item" +npm run make:clone -- src/express/modules/item src/express/modules/post Item Post +``` + +After cloning an express module, register the new routes in src/express/routes.ts: + +```typescript +await importAndUse("./modules/post/postRoutes"); +``` + +After cloning a react module, register the new routes in src/react/routes.tsx: + +```tsx +import { postRoutes } from "./components/post/index"; + +/* ... */ + +const routes: RouteObject[] = [ + { + /* ... */ + children: [ + /* ... */ + ...postRoutes, + ], + }, +]; +``` + +> Always prefer `make:clone` over writing modules from scratch. It replicates your actual patterns. + +### Cleanup + +```bash +npm run make:purge # Remove example modules (item, post, auth, user) +npm run make:purge -- --keep-auth # Remove items but keep auth and user +npm run install:check # Verify .env and database file are accessible +``` + +### Production + +```bash +docker compose -f compose.prod.yaml up --build # Build + start prod containers +# Prod sets NODE_ENV=production, runs: npm run build && npm start +``` + +--- + +## Architecture — key decisions + +### One server (not two) + +There is **one** Node process serving both the Express API and the React frontend via SSR. `server.ts` is the single entry point. Vite runs in middleware mode embedded inside Express — there is no separate Vite dev server to proxy. + +- API routes (`/api/*`) are handled by Express. +- All other routes fall through to the SSR catch-all, which calls `entry-server.tsx`. +- The client then hydrates via `entry-client.tsx`. + +Do not add a second server, a proxy config, or separate ports. + +### Repository pattern — all SQL goes through repositories + +Every database interaction must live inside a `*Repository.ts` class. Actions must never contain raw SQL. + +```ts +// ✅ Correct — action delegates to repository +const browse: RequestHandler = (req, res) => { + const items = itemRepository.findAll(10, 0); + res.json(items); +}; + +// ❌ Wrong — SQL in an action +const browse: RequestHandler = (req, res) => { + const rows = database.prepare("select * from item").all(); + res.json(rows); +}; +``` + +### Explicit runtime casting — never use `as Type` + +SQLite returns raw SQL primitives (`string | number | bigint | null`). Always reconstruct objects with explicit primitive converters: + +```ts +// ✅ Correct +return { id: Number(id), title: String(title), user_id: Number(user_id) }; + +// ❌ Wrong — hides runtime errors +return row as Item; +``` + +### Synchronous SQLite — no async/await in repositories + +`node:sqlite` is synchronous by design. Repositories must not use `async`/`await`. Actions can remain `async` if they need to interact with other async concerns (e.g., `req.body`, external calls). + +```ts +// ✅ Correct repository method +find(byId: number): Item | null { + const query = database.prepare("select id, title from item where id = ?"); + const row = query.get(byId); + // ... +} + +// ❌ Wrong +async find(byId: number): Promise { ... } +``` + +### Soft delete by default + +The `destroy` action uses `softDelete` (sets `deleted_at = datetime('now')`), not `hardDelete`. All read queries filter with `WHERE deleted_at IS NULL`. Do not bypass this without explicit intent. + +### Validation at the edge with Zod + +Input validation belongs in a `*Validator.ts` file using Zod, registered as middleware before actions in `*Routes.ts`. Actions receive already-validated data — they do not re-validate. + +```ts +// In itemRoutes.ts +router.post(BASE_PATH, itemValidator.validate, itemActions.add); +``` + +### Pagination + +Repository `findAll` methods take `(limit: number, offset: number)` arguments. Actions derive `offset` from `req.query.start`. Never query a table without a LIMIT. + +### API contract tests + +`tests/contracts.ts` is the declarative source of truth for API behavior. When adding or modifying endpoints, update the contract file first, then implement to satisfy it. + +--- + +## Security — do not break these + +### CSRF (Client-Side Double-Submit pattern) + +All mutative requests (POST, PUT, PATCH, DELETE) require: +- A header `X-CSRF-Token: ` +- A cookie `__Host-x-csrf-token=` + +The server checks that both values match. The API is stateless — no server-side session storage. + +When testing mutative endpoints with Postman/Insomnia/curl, provide matching values in both the header and the cookie: +``` +Header: X-CSRF-Token: test-token +Cookie: __Host-x-csrf-token=test-token +``` + +### Cookies + +Both auth cookies use the `__Host-` prefix, `SameSite=strict`, and `Path=/`. Do not remove these attributes. The auth cookie (`__Host-auth`) is `HttpOnly`. The CSRF cookie is not (it is written client-side). + +### Helmet and CSP + +Helmet is enabled in all environments. `contentSecurityPolicy` is **disabled in development** (Vite WebSockets conflict) and **enabled in production**. Do not disable Helmet. + +### No CORS + +There is no CORS configuration. Cross-site requests are intentionally blocked. The frontend and API share the same origin. Do not add CORS middleware unless explicitly required by a new requirement. + +### No dangerouslySetInnerHTML + +Do not use `dangerouslySetInnerHTML` anywhere. React's default escaping is the XSS protection. This is not negotiable. + +### Error responses + +Backend errors must be concise: `400`, `401`, `403`, `404`, `500`. Never expose internal details (stack traces, SQL errors, token specifics) in HTTP responses. + +### File uploads + +StartER has no file upload handling. If adding one, store files outside the document root, validate MIME type and size, and never serve them with an executable Content-Type. + +### Environment variables + +Never commit `.env`. Never commit `src/database/data/database.sqlite`. Both are in `.gitignore`. Generate `APP_SECRET` with `openssl rand -hex 32`. + +--- + +## Terminology + +| Term | What it means in this codebase | +|---|---| +| **Module** | A self-contained Express feature folder: `*Routes.ts`, `*Actions.ts`, `*Repository.ts`, `*Validator.ts` | +| **Action** | An Express `RequestHandler` — thin, delegates to repository, sends HTTP response | +| **Repository** | Class encapsulating all SQL for one table — the only place raw SQL is allowed | +| **Validator** | Zod schema + Express middleware that validates `req.body` before the action runs | +| **Contract** | A test declaration in `tests/contracts.ts` describing expected API behavior | +| **SSR outlet** | The `` placeholder in `index.html` where server-rendered HTML is injected | +| **Hydration** | Client-side React taking over the server-rendered DOM via `hydrateRoot` in `entry-client.tsx` | +| **Soft delete** | Setting `deleted_at` timestamp instead of removing a row — default delete strategy | +| **Hard delete** | Permanently removing a row — use only when explicitly required | +| **`make:clone`** | CLI script to duplicate a module with automatic name replacement | +| **`importAndUse`** | Helper in `src/express/routes.ts` that dynamically imports and registers a module router | diff --git a/bin/make-purge.ts b/bin/make-purge.ts index 3306923..85083d2 100644 --- a/bin/make-purge.ts +++ b/bin/make-purge.ts @@ -55,7 +55,7 @@ async function purgeItems(rootDir: string) { // Remove item module files and related React components. await remove(rootDir, "src/express/modules/item"); await remove(rootDir, "src/react/components/item"); - await remove(rootDir, "tests/react/item.test.tsx"); + await remove(rootDir, "tests/react/components/item"); // Remove item routes from Express. await updateFile(rootDir, "src/express/routes.ts", (content) => @@ -100,7 +100,7 @@ async function purgeAuth(rootDir: string) { await remove(rootDir, "src/express/modules/auth"); await remove(rootDir, "src/express/modules/user"); await remove(rootDir, "src/react/components/auth"); - await remove(rootDir, "tests/react/auth.test.tsx"); + await remove(rootDir, "tests/react/components/auth"); // Remove auth/user routes from Express. await updateFile(rootDir, "src/express/routes.ts", (content) => diff --git a/biome.json b/biome.json index eb204df..a00509c 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/package-lock.json b/package-lock.json index 9ecf55d..7faa572 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,27 @@ { "name": "starter", - "version": "2026.05.14", + "version": "2026.06.01", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "starter", - "version": "2026.05.14", + "version": "2026.06.01", "dependencies": { "compression": "^1.8.1", "cookie-parser": "^1.4.7", "express": "^5.2.1", - "express-rate-limit": "^8.4.1", - "helmet": "^8.1.0", + "express-rate-limit": "^8.5.2", + "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", - "nodemailer": "^8.0.7", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "react-router": "^7.14.2", - "zod": "^4.4.1" + "nodemailer": "^8.0.10", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "^7.16.0", + "zod": "^4.4.3" }, "devDependencies": { - "@biomejs/biome": "2.4.13", + "@biomejs/biome": "2.4.16", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/compression": "^1.8.1", @@ -31,19 +31,22 @@ "@types/jsonwebtoken": "^9.0.10", "@types/nodemailer": "^8.0.0", "@types/pluralize": "^0.0.33", - "@types/react": "^19.2.14", + "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/supertest": "^7.2.0", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.5", - "fs-extra": "^11.3.4", + "fs-extra": "^11.3.5", "jsdom": "^29.1.1", "pluralize": "^8.0.0", "supertest": "^7.2.2", - "tsx": "^4.21.0", + "tsx": "^4.22.4", "typescript": "^6.0.3", - "vite": "^8.0.10", + "vite": "^8.0.15", "vitest": "^4.1.5" + }, + "engines": { + "node": ">=22.5.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -98,14 +101,14 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "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", "peer": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -114,9 +117,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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": { @@ -124,9 +127,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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": { @@ -134,13 +137,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -150,9 +153,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "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": { @@ -160,14 +163,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -184,9 +187,9 @@ } }, "node_modules/@biomejs/biome": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.13.tgz", - "integrity": "sha512-gLXOwkOBBg0tr7bDsqlkIh4uFeKuMjxvqsrb1Tukww1iDmHcfr4Uu8MoQxp0Rcte+69+osRNWXwHsu/zxT6XqA==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", + "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", "dev": true, "license": "MIT OR Apache-2.0", "bin": { @@ -200,20 +203,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.4.13", - "@biomejs/cli-darwin-x64": "2.4.13", - "@biomejs/cli-linux-arm64": "2.4.13", - "@biomejs/cli-linux-arm64-musl": "2.4.13", - "@biomejs/cli-linux-x64": "2.4.13", - "@biomejs/cli-linux-x64-musl": "2.4.13", - "@biomejs/cli-win32-arm64": "2.4.13", - "@biomejs/cli-win32-x64": "2.4.13" + "@biomejs/cli-darwin-arm64": "2.4.16", + "@biomejs/cli-darwin-x64": "2.4.16", + "@biomejs/cli-linux-arm64": "2.4.16", + "@biomejs/cli-linux-arm64-musl": "2.4.16", + "@biomejs/cli-linux-x64": "2.4.16", + "@biomejs/cli-linux-x64-musl": "2.4.16", + "@biomejs/cli-win32-arm64": "2.4.16", + "@biomejs/cli-win32-x64": "2.4.16" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.13.tgz", - "integrity": "sha512-2KImO1jhNFBa2oWConyr0x6flxbQpGKv6902uGXpYM62Xyem8U80j441SyUJ8KyngsmKbQjeIv1q2CQfDkNnYg==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", + "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", "cpu": [ "arm64" ], @@ -228,9 +231,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.13.tgz", - "integrity": "sha512-BKrJklbaFN4p1Ts4kPBczo+PkbsHQg57kmJ+vON9u2t6uN5okYHaSr7h/MutPCWQgg2lglaWoSmm+zhYW+oOkg==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", + "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", "cpu": [ "x64" ], @@ -245,9 +248,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.13.tgz", - "integrity": "sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", + "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", "cpu": [ "arm64" ], @@ -262,9 +265,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.13.tgz", - "integrity": "sha512-U5MsuBQW25dXaYtqWWSPM3P96H6Y+fHuja3TQpMNnylocHW0tEbtFTDlUj6oM+YJLntvEkQy4grBvQNUD4+RCg==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", + "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", "cpu": [ "arm64" ], @@ -279,9 +282,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.13.tgz", - "integrity": "sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", + "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", "cpu": [ "x64" ], @@ -296,9 +299,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.13.tgz", - "integrity": "sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", + "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", "cpu": [ "x64" ], @@ -313,9 +316,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.13.tgz", - "integrity": "sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", + "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", "cpu": [ "arm64" ], @@ -330,9 +333,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.4.13", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.13.tgz", - "integrity": "sha512-tTcMkXyBrmHi9BfrD2VNHs/5rYIUKETqsBlYOvSAABwBkJhSDVb5e7wPukftsQbO3WzQkXe6kaztC6WtUOXSoQ==", + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", + "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", "cpu": [ "x64" ], @@ -380,9 +383,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -404,9 +407,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", "dev": true, "funding": [ { @@ -421,7 +424,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -455,9 +458,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", "dev": true, "funding": [ { @@ -534,9 +537,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -551,9 +554,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -568,9 +571,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -585,9 +588,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -602,9 +605,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -619,9 +622,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -636,9 +639,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -653,9 +656,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -670,9 +673,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -687,9 +690,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -704,9 +707,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -721,9 +724,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -738,9 +741,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -755,9 +758,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -772,9 +775,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -789,9 +792,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -806,9 +809,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -823,9 +826,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -840,9 +843,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -857,9 +860,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -874,9 +877,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -891,9 +894,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -908,9 +911,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -925,9 +928,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -942,9 +945,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -959,9 +962,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -976,9 +979,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -1054,9 +1057,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -1074,9 +1077,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1091,9 +1094,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1108,9 +1111,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1125,9 +1128,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1142,9 +1145,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1159,9 +1162,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -1176,9 +1179,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -1193,9 +1196,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -1210,9 +1213,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -1227,9 +1230,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -1244,9 +1247,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -1261,9 +1264,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1278,9 +1281,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1297,9 +1300,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1314,9 +1317,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1331,9 +1334,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "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" }, @@ -1408,9 +1411,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1494,9 +1497,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "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" }, @@ -1579,13 +1582,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/nodemailer": { @@ -1606,9 +1609,9 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -1620,9 +1623,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1661,9 +1664,9 @@ } }, "node_modules/@types/superagent": { - "version": "8.1.9", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", - "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "version": "8.1.10", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz", + "integrity": "sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg==", "dev": true, "license": "MIT", "dependencies": { @@ -1685,13 +1688,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1711,14 +1714,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1732,8 +1735,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1742,16 +1745,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1760,13 +1763,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1787,9 +1790,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -1800,13 +1803,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -1814,14 +1817,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1830,9 +1833,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -1840,13 +1843,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1930,9 +1933,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", "dev": true, "license": "MIT", "dependencies": { @@ -2363,16 +2366,16 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2398,9 +2401,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2411,32 +2414,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escape-html": { @@ -2518,12 +2521,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", - "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -2713,9 +2716,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2788,19 +2791,6 @@ "node": ">= 0.4" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2859,9 +2849,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "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==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2871,12 +2861,15 @@ } }, "node_modules/helmet": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", - "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", "license": "MIT", "engines": { "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" } }, "node_modules/html-encoding-sniffer": { @@ -2942,9 +2935,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -3061,9 +3054,9 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3426,9 +3419,9 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3457,13 +3450,13 @@ } }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", + "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } @@ -3576,9 +3569,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3604,9 +3597,9 @@ } }, "node_modules/nodemailer": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", - "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -3735,9 +3728,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3755,7 +3748,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3803,9 +3796,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -3842,24 +3835,24 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.6" } }, "node_modules/react-is": { @@ -3871,9 +3864,9 @@ "peer": true }, "node_modules/react-router": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", - "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3915,25 +3908,15 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3942,29 +3925,22 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" - } - }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", - "dev": true, - "license": "MIT" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } }, "node_modules/router": { "version": "2.2.0", @@ -4051,9 +4027,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4353,9 +4329,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -4363,9 +4339,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "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": { @@ -4390,22 +4366,22 @@ } }, "node_modules/tldts": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", - "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.28" + "tldts-core": "^7.4.2" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", - "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", "dev": true, "license": "MIT" }, @@ -4453,14 +4429,13 @@ "optional": true }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -4473,17 +4448,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -4501,9 +4493,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", "dev": true, "license": "MIT", "engines": { @@ -4511,9 +4503,9 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -4546,17 +4538,17 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.15", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.15.tgz", + "integrity": "sha512-qpgllRxrLqwsMAGRdLhsEr9bepaOQk1rxH1xT2coBXLaEB/bfkqQj1j7RMxwMfnYrvO1ZnFMiwX+wBVgnsyn0g==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -4572,7 +4564,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4624,19 +4616,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4664,12 +4656,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -4802,9 +4794,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.1.tgz", - "integrity": "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index af70cf8..72c7d1b 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { "type": "module", "name": "starter", - "version": "2026.05.14", + "version": "2026.06.01", "main": "server.ts", + "engines": { + "node": ">=22.5.0" + }, "scripts": { "prepare": "git config core.hooksPath .git-hooks || true", "biome:check": "biome check --error-on-warnings .", @@ -13,7 +16,7 @@ "database:schema:load": "tsx --env-file=.env bin/database-sync schema", "database:seeder:load": "tsx --env-file=.env bin/database-sync seeder", "database:sync": "tsx --env-file=.env bin/database-sync both", - "dev": "tsx --env-file=.env server", + "dev": "tsx watch --include .env --env-file=.env server", "install:check": "vitest run tests/install", "make:clone": "tsx --env-file=.env bin/make-clone", "make:purge": "tsx --env-file=.env bin/make-purge", @@ -22,7 +25,7 @@ "types:check": "tsc --noEmit" }, "devDependencies": { - "@biomejs/biome": "2.4.13", + "@biomejs/biome": "2.4.16", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/compression": "^1.8.1", @@ -32,31 +35,31 @@ "@types/jsonwebtoken": "^9.0.10", "@types/nodemailer": "^8.0.0", "@types/pluralize": "^0.0.33", - "@types/react": "^19.2.14", + "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@types/supertest": "^7.2.0", - "@vitejs/plugin-react": "^6.0.1", + "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.5", - "fs-extra": "^11.3.4", + "fs-extra": "^11.3.5", "jsdom": "^29.1.1", "pluralize": "^8.0.0", "supertest": "^7.2.2", - "tsx": "^4.21.0", + "tsx": "^4.22.4", "typescript": "^6.0.3", - "vite": "^8.0.10", + "vite": "^8.0.15", "vitest": "^4.1.5" }, "dependencies": { "compression": "^1.8.1", "cookie-parser": "^1.4.7", "express": "^5.2.1", - "express-rate-limit": "^8.4.1", - "helmet": "^8.1.0", + "express-rate-limit": "^8.5.2", + "helmet": "^8.2.0", "jsonwebtoken": "^9.0.3", - "nodemailer": "^8.0.7", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "react-router": "^7.14.2", - "zod": "^4.4.1" + "nodemailer": "^8.0.10", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "^7.16.0", + "zod": "^4.4.3" } } diff --git a/server.ts b/server.ts index 7b756cf..94d2f91 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,8 @@ /** + * BEGINNERS: + * You don't need to understand or edit this file to build your app. + * This file contains advanced Vite SSR and backend wiring. + * * Purpose: * Main application entry point. * @@ -20,6 +24,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import fs from "node:fs"; +import http from "node:http"; import express, { type ErrorRequestHandler, type Express } from "express"; import { rateLimit } from "express-rate-limit"; import helmet from "helmet"; @@ -91,6 +96,7 @@ createServer().then((server) => { export async function createServer() { const app = express(); + const httpServer = http.createServer(app); /* ********************************************************************** */ /* Helmet */ @@ -101,9 +107,9 @@ export async function createServer() { // Strict-Transport-Security. See https://helmetjs.github.io/ for details. // // Content-Security-Policy is enabled only in production. - // In development it is disabled because Vite’s HMR relies on + // In development it is disabled because Vite's HMR relies on // WebSocket connections and dynamic module evaluation, which - // are blocked by Helmet’s default CSP. + // are blocked by Helmet's default CSP. app.use( helmet({ contentSecurityPolicy: isProduction, @@ -138,7 +144,7 @@ export async function createServer() { /* Frontend / SSR configuration */ /* ********************************************************************** */ - const maybeVite = await configure(app); + const maybeVite = await configure(app, httpServer); /* ****************************************************************** */ /* Load HTML template and SSR renderer */ @@ -233,7 +239,7 @@ export async function createServer() { app.use(logErrors); app.use(sendErrors); - return app; + return httpServer; } /* ************************************************************************ */ @@ -264,7 +270,7 @@ function readIndexHtml() { * - Create a Vite dev server in middleware mode * - Let Express control routing */ -async function configure(app: Express) { +async function configure(app: Express, httpServer: http.Server) { if (isProduction) { const compression = (await import("compression")).default; @@ -274,7 +280,10 @@ async function configure(app: Express) { // Create Vite server in middleware mode. // Express remains the main HTTP server. const vite = await createViteServer({ - server: { middlewareMode: true }, + server: { + middlewareMode: true, + hmr: { server: httpServer }, + }, appType: "custom", }); diff --git a/src/errors/HttpError.ts b/src/errors/HttpError.ts new file mode 100644 index 0000000..880dd3e --- /dev/null +++ b/src/errors/HttpError.ts @@ -0,0 +1,14 @@ +export class HttpError extends Error { + public status: number; + + constructor(status: number, message?: string) { + super(message); + this.status = status; + } +} + +export class NotFoundError extends HttpError { + constructor(message = "Not Found") { + super(404, message); + } +} diff --git a/src/react/components/ErrorPage.tsx b/src/react/components/ErrorPage.tsx index 3083d7e..6420875 100644 --- a/src/react/components/ErrorPage.tsx +++ b/src/react/components/ErrorPage.tsx @@ -1,36 +1,34 @@ -import { isRouteErrorResponse, Link, useRouteError } from "react-router"; - /** * Purpose: * Root error boundary for the application. * * Responsibilities: - * - Catch and display routing errors (404) - * - Catch and display application crashes (500) + * - Catch and display errors * - Provide a way back to the home page */ -export default function ErrorPage() { - const error = useRouteError(); - let title = "Oops!"; - let message = "An unexpected error has occurred."; +import { isRouteErrorResponse, Link, useRouteError } from "react-router"; +import { HttpError } from "../../errors/HttpError"; +const getTitleAndMessage = (error: unknown): [string, string] => { if (isRouteErrorResponse(error)) { - if (error.status === 404) { - title = "404 - Page Not Found"; - message = - "The page you are looking for does not exist or has been moved."; - } else if (error.status === 401) { - title = "401 - Unauthorized"; - message = "You must be logged in to access this page."; - } else if (error.status === 503) { - title = "503 - Service Unavailable"; - message = "The server is temporarily overloaded. Please try again later."; - } - } else if (error instanceof Error) { - message = error.message; + return [String(error.status), String(error.data)]; + } + + if (error instanceof HttpError) { + return [String(error.status), error.message]; } + if (error instanceof Error) { + return ["Oops!", error.message]; + } + + return ["Oops!", "An unexpected error has occurred."]; +}; + +export default function ErrorPage() { + const [title, message] = getTitleAndMessage(useRouteError()); + return (
{ + return Object.fromEntries( + new Intl.DateTimeFormat("en-US", options) + .formatToParts(date) + .map(({ type, value }) => [type, value]), + ); +} + +/** + * Parses local date and time strings (from ) + * into a native Date object, interpreted in the target timezone. + * + * @param dateStr Format "YYYY-MM-DD" + * @param timeStr Format "HH:mm" + * @param timeZone Target timezone (defaults to VITE_TIMEZONE) + * @returns A Date object representing the moment in UTC. + */ +export function fromInputParts( + dateStr: string, + timeStr: string, + timeZone: string = DEFAULT_TIMEZONE, +): Date { + // 1. Create a UTC date representing the "naive" parts. + // We append 'Z' to treat the input as if it were UTC initially, + // then we calculate the offset difference to "shift" it to the target timezone. + const naiveUtc = new Date(`${dateStr}T${timeStr}Z`); + + /** + * Native JS Dates don't provide an easy way to get the offset of a SPECIFIC timezone + * at a specific moment (especially with DST). + * We use Intl.DateTimeFormat to "render" the date in the target timezone, + * then we parse those parts back into a UTC timestamp to see the difference. + */ + const { year, month, day, hour, minute } = toParts(naiveUtc, { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + }); + const localized = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + ); + const offset = localized - naiveUtc.getTime(); + + return new Date(naiveUtc.getTime() - offset); +} + +/** + * Returns a string in YYYY-MM-DD format based on the target timezone. + * Suitable for . + */ +export function toInputDate( + input: string, + timeZone: string = DEFAULT_TIMEZONE, +): string { + const { year, month, day } = toParts(new Date(input), { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return `${year}-${month}-${day}`; +} + +/** + * Returns a string in HH:mm (24h) format based on the target timezone. + * Suitable for . + */ +export function toInputTime( + input: string, + timeZone: string = DEFAULT_TIMEZONE, +): string { + const { hour, minute } = toParts(new Date(input), { + timeZone, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }); + return `${hour}:${minute}`; +} + +type DisplayOptions = { + timeZone: string; + locale: string; + dateStyle: "full" | "long" | "medium" | "short"; + timeStyle: "full" | "long" | "medium" | "short"; +}; + +const globalDisplayOptions: DisplayOptions = { + timeZone: DEFAULT_TIMEZONE, + locale: "fr-FR", + dateStyle: "long", + timeStyle: "short", +}; + +export function setDisplayOptions(options: Partial) { + Object.assign(globalDisplayOptions, options); +} + +export function getDisplayOptions(): DisplayOptions { + return globalDisplayOptions; +} + +/** + * Formats an ISO string or Date into a human-readable localized string. + */ +export function toDisplayString( + input: string, + options?: Partial, +): string { + const { timeZone, locale, dateStyle, timeStyle } = { + ...globalDisplayOptions, + ...options, + }; + + return new Intl.DateTimeFormat(locale, { + dateStyle, + timeStyle, + timeZone, + }).format(new Date(input)); +} diff --git a/tests/contracts.ts b/tests/contracts.ts index a886837..bcfc432 100644 --- a/tests/contracts.ts +++ b/tests/contracts.ts @@ -32,10 +32,11 @@ export type Json = | boolean | null | undefined - | { [key: string]: Json } - | Json[]; + | JsonObject + | JsonArray; export type JsonObject = { [key: string]: Json }; +export type JsonArray = Json[]; export type Case = { only?: boolean; @@ -50,7 +51,7 @@ export type Case = { }; response: { status: number; - body: unknown; + body?: JsonObject | JsonArray; // Optional hook to run extra assertions on the response and?: (response: { headers: { [key: string]: string } }) => void; }; diff --git a/tests/api/contracts.test.ts b/tests/express/contracts.test.ts similarity index 100% rename from tests/api/contracts.test.ts rename to tests/express/contracts.test.ts diff --git a/tests/api/test-utils.ts b/tests/express/test-utils.ts similarity index 100% rename from tests/api/test-utils.ts rename to tests/express/test-utils.ts diff --git a/tests/react/auth.test.tsx b/tests/react/auth.test.tsx deleted file mode 100644 index dccc99e..0000000 --- a/tests/react/auth.test.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { act, fireEvent, screen } from "@testing-library/react"; -import * as ReactRouter from "react-router"; - -import { - AuthProvider, - useAuth, -} from "../../src/react/components/auth/AuthContext"; -import LogoutForm from "../../src/react/components/auth/LogoutForm"; -import MagicLinkForm from "../../src/react/components/auth/MagicLinkForm"; -import VerifyPage from "../../src/react/components/auth/VerifyPage"; -import { - expectContractCall, - fooUser, - renderHookAsync, - renderWithStub, - requestValue, - setupMocks, -} from "./test-utils"; - -describe("React auth components", () => { - beforeEach(() => { - setupMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - describe("", () => { - it("should render its children", async () => { - await renderWithStub( - "/", - () => hello, world!, - ["/"], - { me: null }, - ); - - await screen.findByText("hello, world!"); - }); - }); - - describe("useAuth()", () => { - it("should be used within ", async () => { - // Avoid exception noise in console - vi.spyOn(console, "error").mockImplementationOnce(() => {}); - - await expect(renderHookAsync(() => useAuth())).rejects.toThrow( - /\buseAuth\b.*\bwithin\b.*\bAuthProvider\b/i, - ); - }); - it("should return an auth object", async () => { - const { result } = await renderHookAsync(() => useAuth(), { - wrapper: AuthProvider, - }); - - const auth = result.current; - - expect(auth).toBeDefined(); - }); - it("should return a check function", async () => { - const { result } = await renderHookAsync(() => useAuth(), { - wrapper: AuthProvider, - }); - - const auth = result.current; - - expect(auth.check()).toBe(auth.me != null); - }); - it("should return a sendMagicLink function", async () => { - const { result } = await renderHookAsync(() => useAuth(), { - wrapper: AuthProvider, - }); - - const auth = result.current; - - await act( - async () => - await auth.sendMagicLink( - requestValue("auth", "magic_link", "success", "email"), - ), - ); - - expectContractCall("auth", "magic_link", "success"); - }); - it("should return a verifyMagicLink function", async () => { - const { result } = await renderHookAsync(() => useAuth(), { - wrapper: AuthProvider, - }); - - const auth = result.current; - - await act( - async () => - await auth.verifyMagicLink( - requestValue("auth", "verify", "success", "token"), - ), - ); - - expectContractCall("auth", "verify", "success"); - }); - it("should return a logout function", async () => { - const { result } = await renderHookAsync(() => useAuth(), { - wrapper: AuthProvider, - }); - - const auth = result.current; - - await act(async () => await auth.logout()); - - expectContractCall("auth", "logout", "anyone"); - }); - it("should throw when logout fails", async () => { - setupMocks({ force500: true }); - - const { result } = await renderHookAsync(() => useAuth(), { - wrapper: AuthProvider, - }); - - const auth = result.current; - - await expect(auth.logout()).rejects.toThrow(/logout/i); - - expectContractCall("auth", "logout", "anyone"); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub("/", MagicLinkForm, ["/"], { me: null }); - await screen.findByRole("form"); - }); - it("should submit email and show confirmation", async () => { - const { user } = await renderWithStub("/", MagicLinkForm, ["/"], { - me: null, - }); - - await user.type( - screen.getByLabelText(/^email$/i), - requestValue("auth", "magic_link", "success", "email"), - ); - await user.click(screen.getByRole("button")); - - expectContractCall("auth", "magic_link", "success"); - }); - it("should fail when email is invalid", async () => { - vi.spyOn(globalThis, "alert").mockImplementationOnce(() => {}); - - await renderWithStub("/", MagicLinkForm, ["/"], { - me: null, - }); - - await fireEvent.submit(screen.getByRole("form")); - - expect(alert).toHaveBeenCalled(); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub("/", LogoutForm, ["/"], { - me: fooUser, - }); - await screen.findByRole("button"); - }); - it("should submit form logout", async () => { - const { user } = await renderWithStub("/", LogoutForm, ["/"], { - me: fooUser, - }); - await screen.findByRole("button"); - - await user.click(screen.getByRole("button")); - - expectContractCall("auth", "logout", "anyone"); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - const mockedNavigate = vi.fn().mockImplementation((_to: string) => {}); - vi.spyOn(ReactRouter, "useNavigate").mockImplementation( - () => mockedNavigate, - ); - - await renderWithStub( - "/verify", - VerifyPage, - [`/verify?token=${requestValue("auth", "verify", "success", "token")}`], - { me: null }, - ); - - await screen.findByText(/in progress/i); - }); - it("should verify token and redirect to dashboard when valid", async () => { - const mockedNavigate = vi.fn().mockImplementation((_to: string) => {}); - vi.spyOn(ReactRouter, "useNavigate").mockImplementation( - () => mockedNavigate, - ); - - await renderWithStub( - "/verify", - VerifyPage, - [`/verify?token=${requestValue("auth", "verify", "success", "token")}`], - { me: null }, - ); - - expectContractCall("auth", "verify", "success"); - - expect(mockedNavigate).toHaveBeenCalledWith("/", { replace: true }); - }); - it("should display error when token is invalid", async () => { - const mockedNavigate = vi.fn().mockImplementation((_to: string) => {}); - vi.spyOn(ReactRouter, "useNavigate").mockImplementation( - () => mockedNavigate, - ); - - await renderWithStub( - "/verify", - VerifyPage, - [ - `/verify?token=${requestValue("auth", "verify", "unauthorized", "token")}`, - ], - { me: null }, - ); - - await screen.findByText(/invalid/i); - - expectContractCall("auth", "verify", "unauthorized"); - expect(mockedNavigate).not.toHaveBeenCalled(); - }); - it("should display error when token is missing", async () => { - const mockedNavigate = vi.fn().mockImplementation((_to: string) => {}); - vi.spyOn(ReactRouter, "useNavigate").mockImplementation( - () => mockedNavigate, - ); - - await renderWithStub("/verify", VerifyPage, ["/verify"], { me: null }); - - await screen.findByText(/invalid/i); - - expect(globalThis.fetch).not.toHaveBeenCalledWith( - "/api/auth/verify", - expect.objectContaining({ - method: "post", - }), - ); - expect(mockedNavigate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/react/base.test.tsx b/tests/react/base.test.tsx deleted file mode 100644 index 246f078..0000000 --- a/tests/react/base.test.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { act, screen } from "@testing-library/react"; -import { - DataRefreshProvider, - useRefresh, -} from "../../src/react/components/DataRefreshContext"; -import Home from "../../src/react/components/Home"; -import Layout from "../../src/react/components/Layout"; -import { cache, invalidateCache } from "../../src/react/helpers/cache"; -import { apiMutate, useMutate } from "../../src/react/helpers/mutate"; -import { - allItems, - expectContractCall, - fooUser, - renderHookAsync, - renderWithStub, - requestValue, - setupMocks, -} from "./test-utils"; - -describe("React: Base Components & Utilities", () => { - beforeEach(() => { - setupMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub("/", () => , ["/"], { me: null }); - - await screen.findByRole("navigation"); - }); - it("should render magic link form when not authenticated", async () => { - await renderWithStub("/", () => , ["/"], { me: null }); - - await screen.findByLabelText(/email/i); - }); - it("should render logout when authenticated", async () => { - await renderWithStub("/", () => , ["/"], { me: fooUser }); - - await screen.findByText(/logout/i); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub("/", () => , ["/"], { me: null }); - - await screen.findByRole("heading", { level: 1 }); - }); - it("should count", async () => { - const { user } = await renderWithStub("/", () => , ["/"], { - me: null, - }); - - await user.click(screen.getByRole("button")); - }); - }); - - describe("cache", () => { - it("should return cached data", async () => { - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - it("should not fetch again when data is cached", async () => { - invalidateCache(`/api/items/${allItems[0].id}`); - - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); - - expect(global.fetch).toHaveBeenCalledTimes(1); - - const data2 = await cache(`/api/items/${allItems[0].id}`); - expect(data2).toEqual(allItems[0]); - - expect(global.fetch).toHaveBeenCalledTimes(1); - expect(global.fetch).toHaveBeenNthCalledWith( - 1, - `/api/items/${allItems[0].id}`, - ); - }); - it("should return null when data is not available", async () => { - const data = await cache("/api/404"); - expect(data).toBeNull(); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - }); - - describe("invalidateCache", () => { - it("should invalidate cache", async () => { - invalidateCache(`/api/items/${allItems[0].id}`); - - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); - - expect(global.fetch).toHaveBeenCalledTimes(1); - - invalidateCache(`/api/items/${allItems[0].id}`); - - const data2 = await cache(`/api/items/${allItems[0].id}`); - expect(data2).toEqual(allItems[0]); - - expect(global.fetch).toHaveBeenCalledTimes(2); - expect(global.fetch).toHaveBeenNthCalledWith( - 2, - `/api/items/${allItems[0].id}`, - ); - }); - it("should invalidate all cache when '*' is provided", async () => { - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); - const data2 = await cache(`/api/users/${fooUser.id}`); - expect(data2).toEqual(fooUser); - - expect(global.fetch).toHaveBeenCalledTimes(2); - - invalidateCache("*"); - - const data3 = await cache(`/api/users/${fooUser.id}`); - expect(data3).toEqual(fooUser); - - expect(global.fetch).toHaveBeenCalledTimes(3); - expect(global.fetch).toHaveBeenNthCalledWith( - 3, - `/api/users/${fooUser.id}`, - ); - }); - it("should not invalidate cache for paths that do not match", async () => { - const data = await cache(`/api/items/${allItems[0].id}`); - expect(data).toEqual(allItems[0]); - const data2 = await cache(`/api/users/${fooUser.id}`); - expect(data2).toEqual(fooUser); - - expect(global.fetch).toHaveBeenCalledTimes(2); - - invalidateCache("/api/items"); - - const data3 = await cache(`/api/users/${fooUser.id}`); - expect(data3).toEqual(fooUser); - - expect(global.fetch).toHaveBeenCalledTimes(2); - }); - }); - - describe("apiMutate", () => { - it("should send a mutation request with a body", async () => { - const { id, user_id, ...itemFields } = allItems[0]; - - await apiMutate(`/api/items/${allItems[0].id}`, "put", { - ...itemFields, - title: requestValue("items", "edit", "success", "title"), - }); - - expectContractCall("items", "edit", "success"); - }); - it("should send a mutation request without a body", async () => { - await apiMutate(`/api/items/${allItems[0].id}`, "delete"); - - expectContractCall("items", "delete", "success"); - }); - }); - - describe("useMutate", () => { - it("should throw an error when used outside of RefreshProvider", async () => { - vi.spyOn(console, "error").mockImplementation(() => {}); - await expect(renderHookAsync(() => useMutate())).rejects.toThrow( - "useRefresh must be used within a DataRefreshProvider", - ); - }); - it("should return a mutate function", async () => { - const { result } = await renderHookAsync(() => useMutate(), { - wrapper: DataRefreshProvider, - }); - - const mutate = result.current; - - await act(() => - mutate(`/api/items/${allItems[0].id}`, "delete", null, ["/api/items"]), - ); - - expectContractCall("items", "delete", "success"); - }); - }); - - describe("useRefresh", () => { - it("should throw an error when used outside of RefreshProvider", async () => { - vi.spyOn(console, "error").mockImplementation(() => {}); - await expect(renderHookAsync(() => useRefresh())).rejects.toThrow( - "useRefresh must be used within a DataRefreshProvider", - ); - }); - it("should return a refresh function", async () => { - const { result } = await renderHookAsync(() => useRefresh(), { - wrapper: DataRefreshProvider, - }); - - const { refresh, tick: initialTick } = result.current; - - act(() => refresh()); - - await renderHookAsync(() => useRefresh(), { - wrapper: DataRefreshProvider, - }); - - const { tick } = result.current; - - expect(tick).toBe(initialTick + 1); - }); - }); -}); diff --git a/tests/react/components/DataRefreshContext.test.tsx b/tests/react/components/DataRefreshContext.test.tsx new file mode 100644 index 0000000..133e727 --- /dev/null +++ b/tests/react/components/DataRefreshContext.test.tsx @@ -0,0 +1,45 @@ +import { act } from "@testing-library/react"; + +import { + DataRefreshProvider, + useRefresh, +} from "../../../src/react/components/DataRefreshContext"; +import { renderHookAsync, setupMocks } from "../test-utils"; + +describe("React Components: DataRefreshContext", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("useRefresh()", () => { + it("should throw an error when used outside of RefreshProvider", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(renderHookAsync(() => useRefresh())).rejects.toThrow( + "useRefresh must be used within a DataRefreshProvider", + ); + }); + + it("should return a refresh function", async () => { + const { result } = await renderHookAsync(() => useRefresh(), { + wrapper: DataRefreshProvider, + }); + + const { refresh, tick: initialTick } = result.current; + + act(() => refresh()); + + await renderHookAsync(() => useRefresh(), { + wrapper: DataRefreshProvider, + }); + + const { tick } = result.current; + + expect(tick).toBe(initialTick + 1); + }); + }); +}); diff --git a/tests/react/components/ErrorPage.test.tsx b/tests/react/components/ErrorPage.test.tsx new file mode 100644 index 0000000..82861e0 --- /dev/null +++ b/tests/react/components/ErrorPage.test.tsx @@ -0,0 +1,91 @@ +import { screen } from "@testing-library/react"; +import { data } from "react-router"; + +import { HttpError, NotFoundError } from "../../../src/errors/HttpError"; +import ErrorPage from "../../../src/react/components/ErrorPage"; +import { renderWithStub, setupMocks } from "../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + // Avoid exception noise in console + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/", + Component: () => , + initialEntries: ["/"], + me: null, + }); + + await screen.findByRole("heading", { level: 1 }); + }); + + it("should handle a route error response", async () => { + await renderWithStub({ + path: "/", + Component: () =>

hello, world!

, + loader: () => { + throw data("Test error", { status: 404 }); + }, + ErrorBoundary: ErrorPage, + initialEntries: ["/"], + me: null, + }); + + await screen.findByRole("heading", { level: 1 }); + await screen.findByText("Test error"); + }); + + it("should handle a 404 error", async () => { + await renderWithStub({ + path: "/", + Component: () => { + throw new NotFoundError(); + }, + ErrorBoundary: ErrorPage, + initialEntries: ["/"], + me: null, + }); + + await screen.findByRole("heading", { level: 1 }); + await screen.findByText("Not Found"); + }); + + it("should handle an HTTP error response", async () => { + await renderWithStub({ + path: "/", + Component: () => { + throw new HttpError(418, "I'm a teapot"); + }, + ErrorBoundary: ErrorPage, + initialEntries: ["/"], + me: null, + }); + + await screen.findByRole("heading", { level: 1 }); + await screen.findByText("I'm a teapot"); + }); + + it("should handle a standard error", async () => { + await renderWithStub({ + path: "/", + Component: () => { + throw new Error("Test error"); + }, + ErrorBoundary: ErrorPage, + initialEntries: ["/"], + me: null, + }); + + await screen.findByRole("heading", { level: 1 }); + await screen.findByText("Test error"); + }); +}); diff --git a/tests/react/components/Home.test.tsx b/tests/react/components/Home.test.tsx new file mode 100644 index 0000000..e2c9cda --- /dev/null +++ b/tests/react/components/Home.test.tsx @@ -0,0 +1,36 @@ +import { screen } from "@testing-library/react"; +import Home from "../../../src/react/components/Home"; +import { renderWithStub, setupMocks } from "../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/", + Component: () => , + initialEntries: ["/"], + me: null, + }); + + await screen.findByRole("heading", { level: 1 }); + }); + + it("should count", async () => { + const { user } = await renderWithStub({ + path: "/", + Component: () => , + initialEntries: ["/"], + me: null, + }); + + await user.click(screen.getByRole("button")); + }); +}); diff --git a/tests/react/components/Layout.test.tsx b/tests/react/components/Layout.test.tsx new file mode 100644 index 0000000..a4e074c --- /dev/null +++ b/tests/react/components/Layout.test.tsx @@ -0,0 +1,47 @@ +import { screen } from "@testing-library/react"; +import Layout from "../../../src/react/components/Layout"; +import { fooUser, renderWithStub, setupMocks } from "../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/", + Component: () => , + initialEntries: ["/"], + me: null, + }); + + await screen.findByRole("navigation"); + }); + + it("should render magic link form when not authenticated", async () => { + await renderWithStub({ + path: "/", + Component: () => , + initialEntries: ["/"], + me: null, + }); + + await screen.findByLabelText(/email/i); + }); + + it("should render logout when authenticated", async () => { + await renderWithStub({ + path: "/", + Component: () => , + initialEntries: ["/"], + me: fooUser, + }); + + await screen.findByText(/logout/i); + }); +}); diff --git a/tests/react/components/auth/AuthContext.test.tsx b/tests/react/components/auth/AuthContext.test.tsx new file mode 100644 index 0000000..ef1d41a --- /dev/null +++ b/tests/react/components/auth/AuthContext.test.tsx @@ -0,0 +1,131 @@ +import { act, screen } from "@testing-library/react"; + +import { + AuthProvider, + useAuth, +} from "../../../../src/react/components/auth/AuthContext"; +import { + expectContractCall, + renderHookAsync, + renderWithStub, + requestValue, + setupMocks, +} from "../../test-utils"; + +describe("React Components: AuthContext", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("", () => { + it("should render its children", async () => { + await renderWithStub({ + path: "/", + Component: () => ( + hello, world! + ), + initialEntries: ["/"], + me: null, + }); + + await screen.findByText("hello, world!"); + }); + }); + + describe("useAuth()", () => { + it("should be used within ", async () => { + // Avoid exception noise in console + vi.spyOn(console, "error").mockImplementationOnce(() => {}); + + await expect(renderHookAsync(() => useAuth())).rejects.toThrow( + /\buseAuth\b.*\bwithin\b.*\bAuthProvider\b/i, + ); + }); + it("should return an auth object", async () => { + const { result } = await renderHookAsync(() => useAuth(), { + wrapper: AuthProvider, + }); + + const auth = result.current; + + expect(auth).toBeDefined(); + }); + it("should return a check function", async () => { + const { result } = await renderHookAsync(() => useAuth(), { + wrapper: AuthProvider, + }); + + const auth = result.current; + + expect(auth.check()).toBe(auth.me != null); + }); + it("should return a sendMagicLink function", async () => { + const { result } = await renderHookAsync(() => useAuth(), { + wrapper: AuthProvider, + }); + + const auth = result.current; + + await act( + async () => + await auth.sendMagicLink( + String(requestValue("auth", "magic_link", "success", "email")), + ), + ); + + expectContractCall("auth", "magic_link", "success"); + }); + it("should return a verifyMagicLink function", async () => { + const { result } = await renderHookAsync(() => useAuth(), { + wrapper: AuthProvider, + }); + + const auth = result.current; + + await act( + async () => + await auth.verifyMagicLink( + String(requestValue("auth", "verify", "success", "token")), + ), + ); + + expectContractCall("auth", "verify", "success"); + }); + it("should return a logout function", async () => { + const { result } = await renderHookAsync(() => useAuth(), { + wrapper: AuthProvider, + }); + + const auth = result.current; + + await act(async () => await auth.logout()); + + expectContractCall("auth", "logout", "anyone"); + }); + it("should throw when logout fails", async () => { + setupMocks({ + force500: [ + { + path: "/api/auth/logout", + method: "post", + }, + ], + }); + + const { result } = await renderHookAsync(() => useAuth(), { + wrapper: AuthProvider, + }); + + const auth = result.current; + + await expect(auth.logout()).rejects.toThrow(/logout/i); + + expectContractCall("auth", "logout", "anyone"); + }); + }); +}); diff --git a/tests/react/components/auth/Logout.test.tsx b/tests/react/components/auth/Logout.test.tsx new file mode 100644 index 0000000..d9750a5 --- /dev/null +++ b/tests/react/components/auth/Logout.test.tsx @@ -0,0 +1,43 @@ +import { screen } from "@testing-library/react"; + +import LogoutForm from "../../../../src/react/components/auth/LogoutForm"; +import { + expectContractCall, + fooUser, + renderWithStub, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/", + Component: LogoutForm, + initialEntries: ["/"], + me: fooUser, + }); + await screen.findByRole("button"); + }); + it("should submit form logout", async () => { + const { user } = await renderWithStub({ + path: "/", + Component: LogoutForm, + initialEntries: ["/"], + me: fooUser, + }); + await screen.findByRole("button"); + + await user.click(screen.getByRole("button")); + + expectContractCall("auth", "logout", "anyone"); + }); +}); diff --git a/tests/react/components/auth/MagicLinkForm.test.tsx b/tests/react/components/auth/MagicLinkForm.test.tsx new file mode 100644 index 0000000..4c4ba85 --- /dev/null +++ b/tests/react/components/auth/MagicLinkForm.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, screen } from "@testing-library/react"; + +import MagicLinkForm from "../../../../src/react/components/auth/MagicLinkForm"; +import { + expectContractCall, + renderWithStub, + requestValue, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/", + Component: MagicLinkForm, + initialEntries: ["/"], + me: null, + }); + await screen.findByRole("form"); + }); + it("should submit email and show confirmation", async () => { + const { user } = await renderWithStub({ + path: "/", + Component: MagicLinkForm, + initialEntries: ["/"], + me: null, + }); + + await user.type( + screen.getByLabelText(/^email$/i), + String(requestValue("auth", "magic_link", "success", "email")), + ); + await user.click(screen.getByRole("button")); + + expectContractCall("auth", "magic_link", "success"); + }); + it("should fail when email is invalid", async () => { + vi.spyOn(globalThis, "alert").mockImplementationOnce(() => {}); + + await renderWithStub({ + path: "/", + Component: MagicLinkForm, + initialEntries: ["/"], + me: null, + }); + + await fireEvent.submit(screen.getByRole("form")); + + expect(alert).toHaveBeenCalled(); + }); +}); diff --git a/tests/react/components/auth/VerifyPage.test.tsx b/tests/react/components/auth/VerifyPage.test.tsx new file mode 100644 index 0000000..efdf80f --- /dev/null +++ b/tests/react/components/auth/VerifyPage.test.tsx @@ -0,0 +1,101 @@ +import { screen } from "@testing-library/react"; +import * as ReactRouter from "react-router"; + +import VerifyPage from "../../../../src/react/components/auth/VerifyPage"; +import { + expectContractCall, + renderWithStub, + requestValue, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + const mockedNavigate = vi.fn().mockImplementation((_to: string) => {}); + vi.spyOn(ReactRouter, "useNavigate").mockImplementation( + () => mockedNavigate, + ); + + await renderWithStub({ + path: "/verify", + Component: VerifyPage, + initialEntries: [ + `/verify?token=${requestValue("auth", "verify", "success", "token")}`, + ], + me: null, + }); + + await screen.findByText(/in progress/i); + }); + it("should verify token and redirect to dashboard when valid", async () => { + const mockedNavigate = vi.fn().mockImplementation((_to: string) => {}); + vi.spyOn(ReactRouter, "useNavigate").mockImplementation( + () => mockedNavigate, + ); + + await renderWithStub({ + path: "/verify", + Component: VerifyPage, + initialEntries: [ + `/verify?token=${requestValue("auth", "verify", "success", "token")}`, + ], + me: null, + }); + + expectContractCall("auth", "verify", "success"); + + expect(mockedNavigate).toHaveBeenCalledWith("/", { replace: true }); + }); + it("should display error when token is invalid", async () => { + const mockedNavigate = vi.fn().mockImplementation((_to: string) => {}); + vi.spyOn(ReactRouter, "useNavigate").mockImplementation( + () => mockedNavigate, + ); + + await renderWithStub({ + path: "/verify", + Component: VerifyPage, + initialEntries: [ + `/verify?token=${requestValue("auth", "verify", "unauthorized", "token")}`, + ], + me: null, + }); + + await screen.findByText(/invalid/i); + + expectContractCall("auth", "verify", "unauthorized"); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); + it("should display error when token is missing", async () => { + const mockedNavigate = vi.fn().mockImplementation((_to: string) => {}); + vi.spyOn(ReactRouter, "useNavigate").mockImplementation( + () => mockedNavigate, + ); + + await renderWithStub({ + path: "/verify", + Component: VerifyPage, + initialEntries: ["/verify"], + me: null, + }); + + await screen.findByText(/invalid/i); + + expect(globalThis.fetch).not.toHaveBeenCalledWith( + "/api/auth/verify", + expect.objectContaining({ + method: "post", + }), + ); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/react/components/item/ItemCreate.test.tsx b/tests/react/components/item/ItemCreate.test.tsx new file mode 100644 index 0000000..06eca77 --- /dev/null +++ b/tests/react/components/item/ItemCreate.test.tsx @@ -0,0 +1,89 @@ +import { screen } from "@testing-library/react"; +import * as ReactRouter from "react-router"; + +import ItemCreate from "../../../../src/react/components/item/ItemCreate"; + +import { + expectContractCall, + fooUser, + renderWithStub, + requestValue, + responseValue, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + + const mockedNavigate = vi.fn(); + vi.spyOn(ReactRouter, "useNavigate").mockImplementation( + () => mockedNavigate, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/items/new", + Component: ItemCreate, + initialEntries: ["/items/new"], + me: fooUser, + }); + + await screen.findByRole("button"); + }); + it("should submit form and create an item", async () => { + const { user } = await renderWithStub({ + path: "/items/new", + Component: ItemCreate, + initialEntries: ["/items/new"], + me: fooUser, + }); + + await user.type( + screen.getByLabelText(/title/i), + String(requestValue("items", "create", "success", "title")), + ); + await user.click(screen.getByRole("button")); + + expectContractCall("items", "create", "success"); + + const navigate = ReactRouter.useNavigate(); + expect(navigate).toHaveBeenCalledWith( + `/items/${responseValue("items", "create", "success", "insertId")}`, + ); + }); + it("should not redirect if server returns an error", async () => { + setupMocks({ + force500: [ + { + path: "/api/items", + method: "post", + }, + ], + }); + + const { user } = await renderWithStub({ + path: "/items/new", + Component: ItemCreate, + initialEntries: ["/items/new"], + me: fooUser, + }); + + await user.type( + screen.getByLabelText(/title/i), + String(requestValue("items", "create", "success", "title")), + ); + await user.click(screen.getByRole("button")); + + expectContractCall("items", "create", "success"); + + const navigate = ReactRouter.useNavigate(); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/react/components/item/ItemDeleteForm.test.tsx b/tests/react/components/item/ItemDeleteForm.test.tsx new file mode 100644 index 0000000..5875e71 --- /dev/null +++ b/tests/react/components/item/ItemDeleteForm.test.tsx @@ -0,0 +1,78 @@ +import { screen } from "@testing-library/react"; +import * as ReactRouter from "react-router"; + +import ItemDeleteForm from "../../../../src/react/components/item/ItemDeleteForm"; + +import { + allItems, + expectContractCall, + fooUser, + renderWithStub, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + + const mockedNavigate = vi.fn(); + vi.spyOn(ReactRouter, "useNavigate").mockImplementation( + () => mockedNavigate, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/items/:id", + Component: ItemDeleteForm, + initialEntries: [`/items/${allItems[0].id}`], + me: fooUser, + }); + + await screen.findByRole("button"); + }); + it("should submit form and delete an item", async () => { + const { user } = await renderWithStub({ + path: "/items/:id", + Component: ItemDeleteForm, + initialEntries: [`/items/${allItems[0].id}`], + me: fooUser, + }); + + await user.click(screen.getByRole("button")); + + expectContractCall("items", "delete", "success"); + + const navigate = ReactRouter.useNavigate(); + expect(navigate).toHaveBeenCalledWith("/items"); + }); + it("should not redirect when server returns an error", async () => { + setupMocks({ + force500: [ + { + path: `/api/items/${allItems[0].id}`, + method: "delete", + }, + ], + }); + + const { user } = await renderWithStub({ + path: "/items/:id", + Component: ItemDeleteForm, + initialEntries: [`/items/${allItems[0].id}`], + me: fooUser, + }); + + await user.click(screen.getByRole("button")); + + expectContractCall("items", "delete", "success"); + + const navigate = ReactRouter.useNavigate(); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/react/components/item/ItemEdit.test.tsx b/tests/react/components/item/ItemEdit.test.tsx new file mode 100644 index 0000000..c68084d --- /dev/null +++ b/tests/react/components/item/ItemEdit.test.tsx @@ -0,0 +1,99 @@ +import { screen } from "@testing-library/react"; +import * as ReactRouter from "react-router"; + +import ItemEdit from "../../../../src/react/components/item/ItemEdit"; + +import { + allItems, + expectContractCall, + fooUser, + renderWithStub, + requestValue, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + + const mockedNavigate = vi.fn(); + vi.spyOn(ReactRouter, "useNavigate").mockImplementation( + () => mockedNavigate, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/items/:id/edit", + Component: ItemEdit, + initialEntries: [`/items/${allItems[0].id}/edit`], + me: fooUser, + }); + + await screen.findByRole("button"); + }); + it("should throw 404 when params contain invalid id", async () => { + await expect(() => + renderWithStub({ + path: "/items/:id/edit", + Component: ItemEdit, + initialEntries: [`/items/${NaN}/edit`], + me: fooUser, + }), + ).rejects.toThrow(/not found/i); + }); + it("should submit form and edit an item", async () => { + const { user } = await renderWithStub({ + path: "/items/:id/edit", + Component: ItemEdit, + initialEntries: [`/items/${allItems[0].id}/edit`], + me: fooUser, + }); + + await user.clear(screen.getByLabelText(/title/i)); + await user.type( + screen.getByLabelText(/title/i), + String(requestValue("items", "edit", "success", "title")), + ); + await user.click(screen.getByRole("button")); + + expectContractCall("items", "edit", "success"); + + const navigate = ReactRouter.useNavigate(); + expect(navigate).toHaveBeenCalledWith(`/items/${allItems[0].id}`); + }); + it("should not redirect when server returns an error", async () => { + setupMocks({ + force500: [ + { + path: `/api/items/${allItems[0].id}`, + method: "put", + }, + ], + }); + + const { user } = await renderWithStub({ + path: "/items/:id/edit", + Component: ItemEdit, + initialEntries: [`/items/${allItems[0].id}/edit`], + me: fooUser, + }); + + await user.clear(screen.getByLabelText(/title/i)); + await user.type( + screen.getByLabelText(/title/i), + String(requestValue("items", "edit", "success", "title")), + ); + await user.click(screen.getByRole("button")); + + expectContractCall("items", "edit", "success"); + + const navigate = ReactRouter.useNavigate(); + expect(navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/react/components/item/ItemForm.test.tsx b/tests/react/components/item/ItemForm.test.tsx new file mode 100644 index 0000000..f6c0cb2 --- /dev/null +++ b/tests/react/components/item/ItemForm.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, screen } from "@testing-library/react"; + +import ItemForm from "../../../../src/react/components/item/ItemForm"; + +import { fooUser, renderWithStub, setupMocks } from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/items/new", + Component: () => ( + {}}> + + + ), + initialEntries: ["/items/new"], + me: fooUser, + }); + + await screen.findByRole("form", { name: /item form/i }); + }); + it("should raise validation errors when submitting", async () => { + vi.spyOn(globalThis, "alert").mockImplementation(() => {}); + + await renderWithStub({ + path: "/items/new", + Component: () => ( + {}}> + + + ), + initialEntries: ["/items/new"], + me: fooUser, + }); + + await fireEvent.submit(screen.getByRole("form", { name: /item form/i })); + + expect(alert).toHaveBeenCalled(); + }); +}); diff --git a/tests/react/components/item/ItemList.test.tsx b/tests/react/components/item/ItemList.test.tsx new file mode 100644 index 0000000..977271e --- /dev/null +++ b/tests/react/components/item/ItemList.test.tsx @@ -0,0 +1,56 @@ +import { screen } from "@testing-library/react"; + +import ItemList from "../../../../src/react/components/item/ItemList"; + +import { + expectContractCall, + fooUser, + renderWithStub, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/items", + Component: ItemList, + initialEntries: ["/items"], + me: fooUser, + }); + + await screen.findByRole("heading", { level: 1, name: /items/i }); + + expectContractCall("items", "browse", "success"); + }); + it("should not display link to create item when anonymous", async () => { + await renderWithStub({ + path: "/items", + Component: ItemList, + initialEntries: ["/items"], + me: null, + }); + + await screen.findByRole("heading", { level: 1, name: /items/i }); + + expect(screen.queryByTestId("items-new")).toBeNull(); + }); + it("should display link to create item when authentified", async () => { + await renderWithStub({ + path: "/items", + Component: ItemList, + initialEntries: ["/items"], + me: fooUser, + }); + + await screen.findByTestId("items-new"); + }); +}); diff --git a/tests/react/components/item/ItemShow.test.tsx b/tests/react/components/item/ItemShow.test.tsx new file mode 100644 index 0000000..cb2638f --- /dev/null +++ b/tests/react/components/item/ItemShow.test.tsx @@ -0,0 +1,69 @@ +import { screen } from "@testing-library/react"; + +import ItemShow from "../../../../src/react/components/item/ItemShow"; + +import { + allItems, + expectContractCall, + fooUser, + renderWithStub, + setupMocks, +} from "../../test-utils"; + +describe("", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("should mount successfully", async () => { + await renderWithStub({ + path: "/items/:id", + Component: ItemShow, + initialEntries: [`/items/${allItems[0].id}`], + me: fooUser, + }); + + await screen.findByRole("heading", { level: 1, name: allItems[0].title }); + + expectContractCall("items", "read", "success"); + }); + it("should throw 404 when params contain invalid id", async () => { + await expect(() => + renderWithStub({ + path: "/items/:id", + Component: ItemShow, + initialEntries: [`/items/${NaN}`], + me: fooUser, + }), + ).rejects.toThrow(/not found/i); + + expectContractCall("items", "read", "not_found"); + }); + it("should not display link to edit item when anonymous", async () => { + await renderWithStub({ + path: "/items/:id", + Component: ItemShow, + initialEntries: [`/items/${allItems[0].id}`], + me: null, + }); + + await screen.findByRole("heading", { level: 1, name: allItems[0].title }); + + expect(screen.queryByTestId(`items-edit-/${allItems[0].id}`)).toBeNull(); + }); + it("should display link to edit item when authentified", async () => { + await renderWithStub({ + path: "/items/:id", + Component: ItemShow, + initialEntries: [`/items/${allItems[0].id}`], + me: fooUser, + }); + + await screen.findByTestId(`items-edit-${allItems[0].id}`); + }); +}); diff --git a/tests/react/helpers/cache.test.ts b/tests/react/helpers/cache.test.ts new file mode 100644 index 0000000..6b7d6fb --- /dev/null +++ b/tests/react/helpers/cache.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { cache, invalidateCache } from "../../../src/react/helpers/cache"; +import { allItems, fooUser, setupMocks } from "../test-utils"; + +describe("React Helpers: cache", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("cache()", () => { + it("should return cached data", async () => { + const data = await cache(`/api/items/${allItems[0].id}`); + expect(data).toEqual(allItems[0]); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("should not fetch again when data is cached", async () => { + invalidateCache(`/api/items/${allItems[0].id}`); + + const data = await cache(`/api/items/${allItems[0].id}`); + expect(data).toEqual(allItems[0]); + + expect(global.fetch).toHaveBeenCalledTimes(1); + + const data2 = await cache(`/api/items/${allItems[0].id}`); + expect(data2).toEqual(allItems[0]); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + `/api/items/${allItems[0].id}`, + ); + }); + + it("should return null when data is not available", async () => { + const data = await cache("/api/404"); + expect(data).toBeNull(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("invalidateCache()", () => { + it("should invalidate cache", async () => { + invalidateCache(`/api/items/${allItems[0].id}`); + + const data = await cache(`/api/items/${allItems[0].id}`); + expect(data).toEqual(allItems[0]); + + expect(global.fetch).toHaveBeenCalledTimes(1); + + invalidateCache(`/api/items/${allItems[0].id}`); + + const data2 = await cache(`/api/items/${allItems[0].id}`); + expect(data2).toEqual(allItems[0]); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + `/api/items/${allItems[0].id}`, + ); + }); + + it("should invalidate all cache when '*' is provided", async () => { + const data = await cache(`/api/items/${allItems[0].id}`); + expect(data).toEqual(allItems[0]); + const data2 = await cache(`/api/users/${fooUser.id}`); + expect(data2).toEqual(fooUser); + + expect(global.fetch).toHaveBeenCalledTimes(2); + + invalidateCache("*"); + + const data3 = await cache(`/api/users/${fooUser.id}`); + expect(data3).toEqual(fooUser); + + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(global.fetch).toHaveBeenNthCalledWith( + 3, + `/api/users/${fooUser.id}`, + ); + }); + + it("should not invalidate cache for paths that do not match", async () => { + const data = await cache(`/api/items/${allItems[0].id}`); + expect(data).toEqual(allItems[0]); + const data2 = await cache(`/api/users/${fooUser.id}`); + expect(data2).toEqual(fooUser); + + expect(global.fetch).toHaveBeenCalledTimes(2); + + invalidateCache("/api/items"); + + const data3 = await cache(`/api/users/${fooUser.id}`); + expect(data3).toEqual(fooUser); + + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/tests/react/helpers/datetime.test.ts b/tests/react/helpers/datetime.test.ts new file mode 100644 index 0000000..28e9c63 --- /dev/null +++ b/tests/react/helpers/datetime.test.ts @@ -0,0 +1,130 @@ +import { + fromInputParts, + getDisplayOptions, + setDisplayOptions, + toDisplayString, + toInputDate, + toInputTime, +} from "../../../src/react/helpers/datetime"; + +describe("React Helpers: datetime", () => { + describe("fromInputParts()", () => { + it("should parse standard date and time (Europe/Paris)", () => { + // 10:00 AM in Paris on May 5th (CEST, UTC+2) -> 08:00 UTC + const date = fromInputParts("2026-05-05", "10:00", "Europe/Paris"); + expect(date.toISOString()).toBe("2026-05-05T08:00:00.000Z"); + }); + + it("should parse standard date and time (America/New_York)", () => { + // 10:00 AM in New York on May 5th (EDT, UTC-4) -> 14:00 UTC + const date = fromInputParts("2026-05-05", "10:00", "America/New_York"); + expect(date.toISOString()).toBe("2026-05-05T14:00:00.000Z"); + }); + + it("should handle DST transition boundaries (Europe/Paris)", () => { + // In CEST (UTC+2) - before transition + const beforeDST = fromInputParts("2026-10-24", "12:00", "Europe/Paris"); + // In CET (UTC+1) - after transition + const afterDST = fromInputParts("2026-10-26", "12:00", "Europe/Paris"); + + // Verify UTC strings reflect the shift in offset + // Oct 24, 12:00 CEST -> 10:00 UTC + // Oct 26, 12:00 CET -> 11:00 UTC + expect(beforeDST.toISOString()).toBe("2026-10-24T10:00:00.000Z"); + expect(afterDST.toISOString()).toBe("2026-10-26T11:00:00.000Z"); + }); + }); + + describe("toInputDate()", () => { + it("should format date as YYYY-MM-DD (Europe/Paris)", () => { + expect(toInputDate("2026-05-05T00:00:00.000Z", "Europe/Paris")).toBe( + "2026-05-05", + ); + }); + + it("should format date as YYYY-MM-DD (America/New_York)", () => { + expect(toInputDate("2026-05-05T00:00:00.000Z", "America/New_York")).toBe( + "2026-05-04", + ); + }); + + it("should return correct date when time is around midnight (Europe/Paris)", () => { + // At 23:00 UTC on the 4th -> 01:00 on the 5th in Paris + expect(toInputDate("2026-05-04T23:00:00.000Z", "Europe/Paris")).toBe( + "2026-05-05", + ); + }); + }); + + describe("toInputTime()", () => { + it("should format time as HH:mm (Europe/Paris)", () => { + // 09:05 UTC -> 11:05 Paris (UTC+2) + expect(toInputTime("2026-05-05T09:05:00.000Z", "Europe/Paris")).toBe( + "11:05", + ); + }); + + it("should format time as HH:mm (America/New_York)", () => { + // 09:05 UTC -> 05:05 AM New York (UTC-4) + expect(toInputTime("2026-05-05T09:05:00.000Z", "America/New_York")).toBe( + "05:05", + ); + }); + + it("should handle midnight correctly (Europe/Paris)", () => { + // 22:00 UTC on May 4th is 00:00 (midnight) on May 5th in Paris + expect(toInputTime("2026-05-04T22:00:00.000Z", "Europe/Paris")).toBe( + "00:00", + ); + }); + }); + + describe("setDisplayOptions()", () => { + const originalOptions = getDisplayOptions(); + + afterAll(() => { + setDisplayOptions(originalOptions); + }); + + it("should update display options", () => { + setDisplayOptions({ timeZone: "America/New_York" }); + expect(getDisplayOptions().timeZone).toBe("America/New_York"); + }); + }); + + describe("toDisplayString", () => { + const originalOptions = getDisplayOptions(); + + afterAll(() => { + setDisplayOptions(originalOptions); + }); + + it("should format date and time as localized string (Europe/Paris)", () => { + const iso = "2026-05-05T09:05:00.000Z"; + // 09:05 UTC -> 11:05 Paris (UTC+2) + setDisplayOptions({ timeZone: "Europe/Paris" }); + expect(toDisplayString(iso)).toContain("5 mai 2026"); + expect(toDisplayString(iso)).toContain("11:05"); + }); + + it("should format date and time as localized string (America/New_York)", () => { + const iso = "2026-05-05T09:05:00.000Z"; + // 09:05 UTC -> 05:05 AM New York (UTC-4) + setDisplayOptions({ timeZone: "America/New_York" }); + expect(toDisplayString(iso)).toContain("5 mai 2026"); + expect(toDisplayString(iso)).toContain("05:05"); + }); + + it("should handle different locales and styles", () => { + const iso = "2026-05-05T12:00:00.000Z"; + setDisplayOptions({ + locale: "en-US", + dateStyle: "short", + timeStyle: "short", + timeZone: "America/New_York", + }); + expect(toDisplayString(iso)).toContain("5/5/26"); + expect(toDisplayString(iso)).toContain("8:00"); + }); + }); +}); diff --git a/tests/react/helpers/mutate.test.tsx b/tests/react/helpers/mutate.test.tsx new file mode 100644 index 0000000..c017eb2 --- /dev/null +++ b/tests/react/helpers/mutate.test.tsx @@ -0,0 +1,95 @@ +import { act } from "@testing-library/react"; + +import { DataRefreshProvider } from "../../../src/react/components/DataRefreshContext"; +import * as cache from "../../../src/react/helpers/cache"; +import { apiMutate, useMutate } from "../../../src/react/helpers/mutate"; +import { + expectContractCall, + fooUser, + renderHookAsync, + requestValue, + setupMocks, +} from "../test-utils"; + +describe("React Helpers: mutate", () => { + beforeEach(() => { + setupMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe("apiMutate()", () => { + it("should send a mutation request with a body", async () => { + await apiMutate(`/api/users/${fooUser.id}`, "put", { + email: requestValue("users", "edit", "success", "email"), + name: requestValue("users", "edit", "success", "name"), + }); + + expectContractCall("users", "edit", "success"); + }); + + it("should send a mutation request without a body", async () => { + await apiMutate(`/api/users/${fooUser.id}`, "delete"); + + expectContractCall("users", "delete", "success"); + }); + }); + + describe("useMutate()", () => { + it("should throw an error when used outside of RefreshProvider", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + await expect(renderHookAsync(() => useMutate())).rejects.toThrow( + "useRefresh must be used within a DataRefreshProvider", + ); + }); + + it("should return a mutate function", async () => { + const { result } = await renderHookAsync(() => useMutate(), { + wrapper: DataRefreshProvider, + }); + + const mutate = result.current; + + expectTypeOf(mutate).toBeFunction(); + }); + + it("should return a mutate function that sends a mutation request and invalidates the cache", async () => { + const invalidateCacheMock = vi.spyOn(cache, "invalidateCache"); + const { result } = await renderHookAsync(() => useMutate(), { + wrapper: DataRefreshProvider, + }); + + const mutate = result.current; + + await act(() => + mutate(`/api/users/${fooUser.id}`, "delete", null, ["/api/users"]), + ); + + expectContractCall("users", "delete", "success"); + expect(invalidateCacheMock).toHaveBeenCalledWith("/api/users"); + }); + + it("should return a mutate function that does not invalidate the cache when the request fails", async () => { + setupMocks({ + force500: [{ path: `/api/users/${fooUser.id}`, method: "delete" }], + }); + + const invalidateCacheMock = vi.spyOn(cache, "invalidateCache"); + const { result } = await renderHookAsync(() => useMutate(), { + wrapper: DataRefreshProvider, + }); + + const mutate = result.current; + + await act(() => + mutate(`/api/users/${fooUser.id}`, "delete", null, ["/api/users"]), + ); + + expectContractCall("users", "delete", "success"); + expect(invalidateCacheMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/react/item.test.tsx b/tests/react/item.test.tsx deleted file mode 100644 index 8964503..0000000 --- a/tests/react/item.test.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { fireEvent, screen } from "@testing-library/react"; -import * as ReactRouter from "react-router"; - -import ItemCreate from "../../src/react/components/item/ItemCreate"; -import ItemDeleteForm from "../../src/react/components/item/ItemDeleteForm"; -import ItemEdit from "../../src/react/components/item/ItemEdit"; -import ItemForm from "../../src/react/components/item/ItemForm"; -import ItemList from "../../src/react/components/item/ItemList"; -import ItemShow from "../../src/react/components/item/ItemShow"; - -import { - allItems, - expectContractCall, - fooUser, - renderWithStub, - requestValue, - setupMocks, -} from "./test-utils"; - -describe("React item components", () => { - beforeEach(() => { - setupMocks(); - vi.spyOn(ReactRouter, "useNavigate").mockImplementation(() => () => {}); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub("/items/new", ItemCreate, ["/items/new"], { - me: fooUser, - }); - - await screen.findByRole("button"); - }); - it("should submit form and create an item", async () => { - const { user } = await renderWithStub( - "/items/new", - ItemCreate, - ["/items/new"], - { me: fooUser }, - ); - - await user.type( - screen.getByLabelText(/title/i), - requestValue("items", "create", "success", "title"), - ); - await user.click(screen.getByRole("button")); - - expectContractCall("items", "create", "success"); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub( - "/items/:id", - ItemDeleteForm, - [`/items/${allItems[0].id}`], - { me: fooUser }, - ); - - await screen.findByRole("button"); - }); - it("should submit form and delete an item", async () => { - const { user } = await renderWithStub( - "/items/:id", - ItemDeleteForm, - [`/items/${allItems[0].id}`], - { me: fooUser }, - ); - - await user.click(screen.getByRole("button")); - - expectContractCall("items", "delete", "success"); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub( - "/items/:id/edit", - ItemEdit, - [`/items/${allItems[0].id}/edit`], - { me: fooUser }, - ); - - await screen.findByRole("button"); - }); - it("should throw 404 when params contain invalid id", async () => { - await expect(() => - renderWithStub("/items/:id/edit", ItemEdit, [`/items/${NaN}/edit`], { - me: fooUser, - }), - ).rejects.toThrow(/404/); - }); - it("should submit form and edit an item", async () => { - const { user } = await renderWithStub( - "/items/:id/edit", - ItemEdit, - [`/items/${allItems[0].id}/edit`], - { me: fooUser }, - ); - - await user.clear(screen.getByLabelText(/title/i)); - await user.type( - screen.getByLabelText(/title/i), - requestValue("items", "edit", "success", "title"), - ); - await user.click(screen.getByRole("button")); - - expectContractCall("items", "edit", "success"); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub( - "/items/new", - () => ( - {}}> - - - ), - ["/items/new"], - { me: fooUser }, - ); - - await screen.findByRole("form", { name: /item form/i }); - }); - it("should raise validation errors when submitting", async () => { - vi.spyOn(globalThis, "alert").mockImplementation(() => {}); - - await renderWithStub( - "/items/new", - () => ( - {}}> - - - ), - ["/items/new"], - { me: fooUser }, - ); - - await fireEvent.submit(screen.getByRole("form", { name: /item form/i })); - - expect(alert).toHaveBeenCalled(); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub("/items", ItemList, ["/items"], { me: fooUser }); - - await screen.findByRole("heading", { level: 1, name: /items/i }); - - expectContractCall("items", "browse", "success"); - }); - it("should not display link to create item when anonymous", async () => { - await renderWithStub("/items", ItemList, ["/items"], { me: null }); - - await screen.findByRole("heading", { level: 1, name: /items/i }); - - expect(screen.queryByTestId("items-new")).toBeNull(); - }); - it("should display link to create item when authentified", async () => { - await renderWithStub("/items", ItemList, ["/items"], { me: fooUser }); - - await screen.findByTestId("items-new"); - }); - }); - - describe("", () => { - it("should mount successfully", async () => { - await renderWithStub( - "/items/:id", - ItemShow, - [`/items/${allItems[0].id}`], - { me: fooUser }, - ); - - await screen.findByRole("heading", { level: 1, name: allItems[0].title }); - - expectContractCall("items", "read", "success"); - }); - it("should throw 404 when params contain invalid id", async () => { - await expect(() => - renderWithStub("/items/:id", ItemShow, [`/items/${NaN}`], { - me: fooUser, - }), - ).rejects.toThrow(/404/); - - expectContractCall("items", "read", "not_found"); - }); - it("should not display link to edit item when anonymous", async () => { - await renderWithStub( - "/items/:id", - ItemShow, - [`/items/${allItems[0].id}`], - { me: null }, - ); - - await screen.findByRole("heading", { level: 1, name: allItems[0].title }); - - expect(screen.queryByTestId(`items-edit-/${allItems[0].id}`)).toBeNull(); - }); - it("should display link to edit item when authentified", async () => { - await renderWithStub( - "/items/:id", - ItemShow, - [`/items/${allItems[0].id}`], - { me: fooUser }, - ); - - await screen.findByTestId(`items-edit-${allItems[0].id}`); - }); - }); -}); diff --git a/tests/react/test-utils.tsx b/tests/react/test-utils.tsx index 6e8bab6..a7a4f6d 100644 --- a/tests/react/test-utils.tsx +++ b/tests/react/test-utils.tsx @@ -135,39 +135,44 @@ type StubRouteObject = Parameters[0][number]; // Wrapping render() in act() because React's use() is suspending // see https://github.com/testing-library/react-testing-library/issues/1375#issuecomment-2582198933 -export const renderWithStub = async ( - path: StubRouteObject["path"], - Component: StubRouteObject["Component"], - initialEntries: string[], - options: { me: User | null }, -) => { +export const renderWithStub = async ({ + path, + Component, + ErrorBoundary, + loader, + initialEntries, + me, +}: { + path: NonNullable; + Component: NonNullable; + ErrorBoundary?: StubRouteObject["ErrorBoundary"]; + loader?: StubRouteObject["loader"]; + initialEntries: string[]; + me: User | null; +}) => { const Stub = createRoutesStub([ { path, - Component: (props) => { - if (Component == null) { - return null; - } - return ( - - - - - - ); - }, + HydrateFallback: () => null, + Component: () => ( + + + + + + ), ErrorBoundary: - // Catch component errors and report them to the test runner - ({ error }) => { + ErrorBoundary ?? + (({ error }: { error: unknown }) => { throw error; - }, + }), + loader, }, ]); - const user = userEvent.setup(); const view = await act(async () => render(), ); - return { user, ...view }; + return { ...view, user: userEvent.setup() }; }; const mockedRandomUUID = "a-b-c-d-e"; @@ -177,13 +182,13 @@ export const setupMocks = ({ force500, }: { forceCases?: Record<`${string}.${string}`, keyof Test["cases"]>; - force500?: boolean; + force500?: { path: string; method: "get" | "post" | "put" | "delete" }[]; } = {}) => { vi.stubGlobal("cookieStore", { get: vi.fn(), set: vi.fn() }); vi.spyOn(crypto, "randomUUID").mockImplementation(() => mockedRandomUUID); const customFetch = (path: string, method: string) => { - if (force500) { + if (force500?.some((f) => f.path === path && f.method === method)) { return respond(null, 500); } if (forceCases) { @@ -216,8 +221,21 @@ export const requestValue = ( field: string, ) => { const body = contracts[contractName][testName].cases[caseName].request.body; - if (typeof body === "object" && body !== null && !Array.isArray(body)) { - return body[field]?.toString() ?? ""; + if (body != null && typeof body === "object" && !Array.isArray(body)) { + return body[field]; + } + throw new Error(`Case body is not an object: ${JSON.stringify(body)}`); +}; + +export const responseValue = ( + contractName: keyof typeof contracts, + testName: keyof Contract, + caseName: keyof Test["cases"], + field: string, +) => { + const body = contracts[contractName][testName].cases[caseName].response.body; + if (body != null && typeof body === "object" && !Array.isArray(body)) { + return JSON.parse(JSON.stringify(body[field])); } throw new Error(`Case body is not an object: ${JSON.stringify(body)}`); }; diff --git a/tsconfig.json b/tsconfig.json index 82e478b..4790190 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,6 @@ "noFallthroughCasesInSwitch": true, "allowJs": true, "esModuleInterop": true, - "types": ["vitest/globals"] + "types": ["vitest/globals", "vite/client"] } }