From 560c4e1746e592699cfb0a1bceb81bbcb02952bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:10:33 +0000 Subject: [PATCH 1/3] feat: slim down dead weight, close the core-loop hole, fix env-coupled auth gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core-loop fixes: - The Learn page's Start Session recommendation now actually creates a session and opens it (the core loop was previously reachable only through full 7-day plan creation); objective ids reuse mastery keys so spaced repetition stays continuous - Worked examples get a plan-template block on day 1 (first exposure) - Completing a session finally awards XP (previously only flashcard reviews moved the daily goal, nudging toward card grinding over retrieval) - The calibration dashboard survives page reloads (refetches the completed run's attempts) - Reschedule missed sessions button on the plan page (preview diff + apply), giving ~900 lines of tested reflow logic its missing UI - JSONB metric reads spread defaults so malformed rows can't produce NaNs Cuts (verified dead or placebo): - Web push stack (nothing ever sent one), service worker, VAPID setup burden - Email reminder pipeline (nothing scheduled AND nothing processed; settings advertised reminders that never sent an email) — auth emails kept - Leaderboard of one user plus the onboarding display-name step that existed only to name yourself for it - Practice-sets and evidence CRUD routes (curl-only, data never consumed) - start.bat (killed every Node process on the machine) - OFFICE_HOURS_PREP mode (declared everywhere, implemented nowhere) - Dead asset pipeline (Button/Icon had zero importers) and 46 packages (web-push, sharp, svgo, fast-glob); dotenv declared explicitly Auth gates (the family behind every past lockout): - Email verification sign-in gate now behind REQUIRE_EMAIL_VERIFICATION, default off (single user; the console provider cannot deliver links) - X-User-Id header trust only under ALLOW_TEST_AUTH (dev sessions can no longer silently split data across phantom localStorage identities) - Clients no longer send identity headers anywhere; cookies carry auth; e2e specs authenticate via Playwright context headers instead - Onboarding copy now points at the Plan page where upload actually lives Verified: tsc clean; 598 unit tests passing. --- e2e/knowledge-layer.spec.ts | 15 +- e2e/session-runner.spec.ts | 28 +- package-lock.json | 1105 +---------------- package.json | 8 +- public/sw.js | 50 - scripts/assets-build.ts | 291 ----- scripts/assets-check.ts | 83 -- src/__tests__/assets-pipeline.test.ts | 149 --- src/__tests__/calendar.test.ts | 1 - src/app/api/content/anchors/build/route.ts | 51 - src/app/api/evidence/cards/route.ts | 72 -- .../api/evidence/papers/[id]/cards/route.ts | 68 - src/app/api/evidence/papers/route.ts | 71 -- src/app/api/leaderboard/route.ts | 108 -- .../api/practice-sets/[id]/import/route.ts | 67 - .../api/practice-sets/[id]/questions/route.ts | 43 - src/app/api/practice-sets/route.ts | 96 -- src/app/api/push/send/route.ts | 85 -- src/app/api/push/subscribe/route.ts | 62 - src/app/api/push/unsubscribe/route.ts | 34 - src/app/api/push/vapid-key/route.ts | 8 - src/app/api/reminders/process/route.ts | 56 - src/app/api/reminders/route.ts | 63 - src/app/layout.tsx | 2 - src/app/learn/page.tsx | 51 +- src/app/page.tsx | 173 +-- src/app/plan/page.tsx | 78 +- src/app/s/[sessionId]/session-runner.tsx | 40 +- src/lib/auth-options.ts | 6 +- src/lib/auth.ts | 2 +- src/lib/calendar.ts | 1 - src/lib/client-api.ts | 8 +- src/lib/email/scheduler.ts | 298 ----- src/lib/plan-generator.ts | 13 +- src/lib/push/index.ts | 85 -- src/lib/push/messages.ts | 76 -- src/lib/validation.ts | 1 - src/middleware.ts | 3 +- src/services/reflow.ts | 1 - src/services/run.ts | 26 +- src/ui/components/Button.tsx | 90 -- src/ui/components/Icon.tsx | 33 - .../components/ServiceWorkerRegistration.tsx | 15 - start.bat | 57 - 44 files changed, 251 insertions(+), 3422 deletions(-) delete mode 100644 public/sw.js delete mode 100644 scripts/assets-build.ts delete mode 100644 scripts/assets-check.ts delete mode 100644 src/__tests__/assets-pipeline.test.ts delete mode 100644 src/app/api/content/anchors/build/route.ts delete mode 100644 src/app/api/evidence/cards/route.ts delete mode 100644 src/app/api/evidence/papers/[id]/cards/route.ts delete mode 100644 src/app/api/evidence/papers/route.ts delete mode 100644 src/app/api/leaderboard/route.ts delete mode 100644 src/app/api/practice-sets/[id]/import/route.ts delete mode 100644 src/app/api/practice-sets/[id]/questions/route.ts delete mode 100644 src/app/api/practice-sets/route.ts delete mode 100644 src/app/api/push/send/route.ts delete mode 100644 src/app/api/push/subscribe/route.ts delete mode 100644 src/app/api/push/unsubscribe/route.ts delete mode 100644 src/app/api/push/vapid-key/route.ts delete mode 100644 src/app/api/reminders/process/route.ts delete mode 100644 src/app/api/reminders/route.ts delete mode 100644 src/lib/email/scheduler.ts delete mode 100644 src/lib/push/index.ts delete mode 100644 src/lib/push/messages.ts delete mode 100644 src/ui/components/Button.tsx delete mode 100644 src/ui/components/Icon.tsx delete mode 100644 src/ui/components/ServiceWorkerRegistration.tsx delete mode 100644 start.bat diff --git a/e2e/knowledge-layer.spec.ts b/e2e/knowledge-layer.spec.ts index eb2c454..84a640d 100644 --- a/e2e/knowledge-layer.spec.ts +++ b/e2e/knowledge-layer.spec.ts @@ -5,10 +5,9 @@ const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; const USER_ID = "e2e_test_user"; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); -// Override extraHTTPHeaders to remove X-User-Id — we set it via localStorage -// and the client JS adds it to fetch calls. Having Playwright ALSO inject it -// causes duplicate headers which break the ownership check. -test.use({ extraHTTPHeaders: {} }); +// Playwright context headers ride on every browser request, so app fetches +// authenticate as the test user (clients no longer send identity headers). +test.use({ extraHTTPHeaders: { "X-User-Id": USER_ID } }); test.describe.serial("Knowledge Layer — Leak Prevention", () => { const COURSE = "E2E_CS_KL"; @@ -78,10 +77,6 @@ test.describe.serial("Knowledge Layer — Leak Prevention", () => { test("review panel NOT visible before submitting answer", async ({ page }) => { const sessionUrl = `${BASE_URL}/s/${sessionId}`; await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); @@ -98,10 +93,6 @@ test.describe.serial("Knowledge Layer — Leak Prevention", () => { test("review panel appears after INCORRECT scoring with deferred feedback", async ({ page }) => { const sessionUrl = `${BASE_URL}/s/${sessionId}`; await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); diff --git a/e2e/session-runner.spec.ts b/e2e/session-runner.spec.ts index bd16c86..3a48898 100644 --- a/e2e/session-runner.spec.ts +++ b/e2e/session-runner.spec.ts @@ -23,10 +23,9 @@ const SESSION_PAYLOAD = { let sessionId: string; let sessionUrl: string; -// Override extraHTTPHeaders to remove X-User-Id — we set it via localStorage -// and the client JS adds it to fetch calls. Having Playwright ALSO inject it -// causes duplicate headers which break the ownership check. -test.use({ extraHTTPHeaders: {} }); +// Playwright context headers ride on every browser request, so app fetches +// authenticate as the test user (clients no longer send identity headers). +test.use({ extraHTTPHeaders: { "X-User-Id": USER_ID } }); test.describe.serial("E2E: Full Retrieval Session Runner", () => { test.beforeAll(async () => { @@ -80,11 +79,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { }); test("start session and see first prompt", async ({ page }) => { - // Need to set the user ID in localStorage before starting - await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); await page.goto(sessionUrl); await page.getByRole("button", { name: /start session/i }).click(); @@ -96,10 +90,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { test("submit a CORRECT answer", async ({ page }) => { await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); @@ -125,10 +115,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { test("submit an INCORRECT answer with error log", async ({ page }) => { await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); await expect(page.locator("textarea")).toBeVisible({ timeout: 5000 }); @@ -160,10 +146,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { test("refresh page mid-run preserves progress", async ({ page }) => { await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); // Should show Resume (not Start) since we have an active run await expect(page.getByRole("button", { name: /resume session/i })).toBeVisible({ @@ -178,10 +160,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { test("complete final prompt and see end screen", async ({ page }) => { await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); // The review panel renders reflection textareas too, so target the diff --git a/package-lock.json b/package-lock.json index ed7f2e5..4b4b4ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "pg": "^8.20.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "web-push": "^3.6.7", "zod": "^4.3.6" }, "devDependencies": { @@ -31,12 +30,10 @@ "@types/react": "^19.2.14", "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.0.1", + "dotenv": "^17.4.2", "eslint": "^8.57.1", "eslint-config-next": "^14.2.35", - "fast-glob": "^3.3.3", "prisma": "^7.6.0", - "sharp": "^0.34.5", - "svgo": "^4.0.1", "tsx": "^4.21.0", "typescript": "^6.0.2", "vitest": "^4.1.2" @@ -783,496 +780,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3224,15 +2731,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -3453,18 +2951,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3567,19 +3053,6 @@ "better-result": "bin/cli.mjs" } }, - "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "license": "MIT" - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -3591,25 +3064,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3650,6 +3104,19 @@ } } }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3822,16 +3289,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3856,117 +3313,37 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "dependencies": { - "css-tree": "~2.2.0" - }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">= 0.6" } }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "dev": true, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, "license": "MIT", "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">= 8" } }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4144,70 +3521,11 @@ "node": ">=0.10.0" } }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4238,15 +3556,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/effect": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", @@ -4275,19 +3584,6 @@ "node": ">=14" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -5120,36 +4416,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5222,19 +4488,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5681,15 +4934,6 @@ "node": ">=16.9.0" } }, - "node_modules/http_ece": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", - "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -5697,36 +4941,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -5797,6 +5011,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -6067,16 +5282,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -6382,27 +5587,6 @@ "node": ">=4.0" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6793,56 +5977,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6860,6 +5994,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7079,19 +6214,6 @@ "node": ">=6.0.0" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, "node_modules/nypm": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", @@ -8184,26 +7306,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8243,18 +7345,9 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, "license": "MIT" }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -8329,64 +7422,6 @@ "node": ">= 0.4" } }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8866,32 +7901,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svgo": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", - "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^11.1.0", - "css-select": "^5.1.0", - "css-tree": "^3.0.1", - "css-what": "^6.1.0", - "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "^1.5.0" - }, - "bin": { - "svgo": "bin/svgo.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -8943,19 +7952,6 @@ "node": ">=14.0.0" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9429,25 +8425,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-push": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", - "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", - "license": "MPL-2.0", - "dependencies": { - "asn1.js": "^5.3.0", - "http_ece": "1.2.0", - "https-proxy-agent": "^7.0.0", - "jws": "^4.0.0", - "minimist": "^1.2.5" - }, - "bin": { - "web-push": "src/cli.js" - }, - "engines": { - "node": ">= 16" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index e722a61..eee670b 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,6 @@ "test:integration": "vitest run src/__tests__/integration/", "test:watch": "vitest", "test:e2e": "playwright test", - "assets:build": "tsx scripts/assets-build.ts", - "assets:check": "tsx scripts/assets-check.ts", - "prebuild": "npm run assets:build", "worker": "tsx scripts/worker.ts", "db:up": "docker compose up -d db", "db:down": "docker compose down", @@ -40,7 +37,6 @@ "pg": "^8.20.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "web-push": "^3.6.7", "zod": "^4.3.6" }, "devDependencies": { @@ -52,12 +48,10 @@ "@types/react": "^19.2.14", "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.0.1", + "dotenv": "^17.4.2", "eslint": "^8.57.1", "eslint-config-next": "^14.2.35", - "fast-glob": "^3.3.3", "prisma": "^7.6.0", - "sharp": "^0.34.5", - "svgo": "^4.0.1", "tsx": "^4.21.0", "typescript": "^6.0.2", "vitest": "^4.1.2" diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index 0bbc025..0000000 --- a/public/sw.js +++ /dev/null @@ -1,50 +0,0 @@ -// ── Study Bot Service Worker ──────────────────────────────────── -// Handles push notifications and notification click events. - -self.addEventListener("activate", (event) => { - // Claim all open clients so the SW takes effect immediately - event.waitUntil(self.clients.claim()); -}); - -self.addEventListener("push", (event) => { - if (!event.data) return; - - let payload; - try { - payload = event.data.json(); - } catch { - payload = { title: "Study Bot", body: event.data.text() }; - } - - const { title = "Study Bot", body = "", url = "/", icon = "/assets/icon-192.png" } = payload; - - event.waitUntil( - self.registration.showNotification(title, { - body, - icon, - badge: icon, - data: { url }, - }), - ); -}); - -self.addEventListener("notificationclick", (event) => { - event.notification.close(); - - const targetUrl = event.notification.data?.url || "/"; - - event.waitUntil( - self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { - // If there's already an open tab, focus it and navigate - for (const client of clientList) { - if (client.url.includes(self.location.origin) && "focus" in client) { - client.focus(); - client.navigate(targetUrl); - return; - } - } - // Otherwise open a new window - return self.clients.openWindow(targetUrl); - }), - ); -}); diff --git a/scripts/assets-build.ts b/scripts/assets-build.ts deleted file mode 100644 index 0fd1f30..0000000 --- a/scripts/assets-build.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Asset optimization pipeline. - * - * Reads raw exports from design/exports-raw/, - * outputs optimized assets to public/assets/ui/, - * generates src/ui/assets/manifest.ts. - * - * Usage: npx tsx scripts/assets-build.ts - * - * PNG → AVIF (q55) + WebP (q80), strip metadata, optional @2x→@1x downscale - * SVG → SVGO optimized - */ -import { mkdir, writeFile, rm } from "fs/promises"; -import { existsSync } from "fs"; -import path from "path"; -import sharp from "sharp"; -import { optimize } from "svgo"; -import { readFile } from "fs/promises"; -import fg from "fast-glob"; - -// ---- Config ---- - -const RAW_DIR = "design/exports-raw"; -const OUT_DIR = "public/assets/ui"; -const MANIFEST_PATH = "src/ui/assets/manifest.ts"; - -const AVIF_QUALITY = 55; -const WEBP_QUALITY = 80; - -const MAX_FILE_SIZE = 300 * 1024; // 300KB -const MAX_DIMENSION = 2000; -const MAX_PIXELS = 4_000_000; - -// Assets allowed to exceed pixel budget (e.g. hero backgrounds) -const PIXEL_ALLOWLIST: string[] = []; - -// ---- Types ---- - -interface AssetEntry { - key: string; - avif?: string; - webp?: string; - svg?: string; - width?: number; - height?: number; -} - -interface BuildResult { - assets: AssetEntry[]; - warnings: string[]; - errors: string[]; -} - -// ---- SVG optimization config ---- - -const SVGO_CONFIG = { - plugins: [ - { name: "removeDoctype" as const }, - { name: "removeXMLProcInst" as const }, - { name: "removeComments" as const }, - { name: "removeMetadata" as const }, - { name: "removeEditorsNSData" as const }, - { name: "removeDesc" as const, params: { removeAny: true } }, - { name: "removeUselessDefs" as const }, - { name: "cleanupAttrs" as const }, - { name: "removeEmptyAttrs" as const }, - { name: "removeEmptyContainers" as const }, - { name: "sortAttrs" as const }, - { name: "removeDimensions" as const, active: false }, - ], -}; - -// ---- Main ---- - -export async function buildAssets(): Promise { - const warnings: string[] = []; - const errors: string[] = []; - const assets: AssetEntry[] = []; - - // Clean and recreate output dir - if (existsSync(OUT_DIR)) { - await rm(OUT_DIR, { recursive: true }); - } - await mkdir(OUT_DIR, { recursive: true }); - - // Scan raw exports - const pngFiles = await fg("**/*.png", { cwd: RAW_DIR }); - const svgFiles = await fg("**/*.svg", { cwd: RAW_DIR }); - - // Sort for deterministic output - pngFiles.sort(); - svgFiles.sort(); - - // Process PNGs - for (const file of pngFiles) { - const fullPath = path.join(RAW_DIR, file); - const basename = path.basename(file, ".png"); - - try { - const img = sharp(fullPath); - const meta = await img.metadata(); - const w = meta.width!; - const h = meta.height!; - - // Dimension warnings - if (w > MAX_DIMENSION || h > MAX_DIMENSION) { - warnings.push(`${file}: dimensions ${w}x${h} exceed ${MAX_DIMENSION}px`); - } - - // Pixel budget - if (w * h > MAX_PIXELS && !PIXEL_ALLOWLIST.includes(basename)) { - errors.push(`${file}: ${w * h} pixels exceeds ${MAX_PIXELS} limit`); - continue; - } - - // Generate AVIF + WebP - const avifPath = path.join(OUT_DIR, `${basename}.avif`); - const webpPath = path.join(OUT_DIR, `${basename}.webp`); - - await sharp(fullPath) - .avif({ quality: AVIF_QUALITY }) - .toFile(avifPath); - - await sharp(fullPath) - .webp({ quality: WEBP_QUALITY }) - .toFile(webpPath); - - assets.push({ - key: basename, - avif: `/assets/ui/${basename}.avif`, - webp: `/assets/ui/${basename}.webp`, - width: w, - height: h, - }); - - // Handle @2x → @1x downscale - if (basename.endsWith("@2x")) { - const baseKey = basename.replace(/@2x$/, ""); - const halfW = Math.round(w / 2); - const halfH = Math.round(h / 2); - - const avif1x = path.join(OUT_DIR, `${baseKey}@1x.avif`); - const webp1x = path.join(OUT_DIR, `${baseKey}@1x.webp`); - - await sharp(fullPath) - .resize(halfW, halfH) - .avif({ quality: AVIF_QUALITY }) - .toFile(avif1x); - - await sharp(fullPath) - .resize(halfW, halfH) - .webp({ quality: WEBP_QUALITY }) - .toFile(webp1x); - - assets.push({ - key: `${baseKey}@1x`, - avif: `/assets/ui/${baseKey}@1x.avif`, - webp: `/assets/ui/${baseKey}@1x.webp`, - width: halfW, - height: halfH, - }); - } - } catch (err) { - errors.push(`${file}: processing failed — ${err}`); - } - } - - // Process SVGs - for (const file of svgFiles) { - const fullPath = path.join(RAW_DIR, file); - const basename = path.basename(file, ".svg"); - - try { - const raw = await readFile(fullPath, "utf-8"); - const result = optimize(raw, { - path: fullPath, - ...SVGO_CONFIG, - }); - - const outPath = path.join(OUT_DIR, `${basename}.svg`); - await writeFile(outPath, result.data, "utf-8"); - - // Parse width/height from SVG if present - const wMatch = raw.match(/width="(\d+)"/); - const hMatch = raw.match(/height="(\d+)"/); - - assets.push({ - key: basename, - svg: `/assets/ui/${basename}.svg`, - width: wMatch ? parseInt(wMatch[1], 10) : undefined, - height: hMatch ? parseInt(hMatch[1], 10) : undefined, - }); - } catch (err) { - errors.push(`${file}: SVG optimization failed — ${err}`); - } - } - - // Sort assets by key for deterministic manifest - assets.sort((a, b) => a.key.localeCompare(b.key)); - - // Budget check: output file sizes - const outputFiles = await fg("**/*", { cwd: OUT_DIR, stats: true }); - for (const entry of outputFiles) { - // fast-glob with stats returns stats on the entry - const filePath = path.join(OUT_DIR, entry.path); - const { size } = await import("fs").then((fs) => - fs.promises.stat(filePath) - ); - if (size > MAX_FILE_SIZE) { - errors.push( - `${entry.path}: ${(size / 1024).toFixed(1)}KB exceeds ${MAX_FILE_SIZE / 1024}KB budget` - ); - } - } - - // Generate manifest - await generateManifest(assets); - - return { assets, warnings, errors }; -} - -// ---- Manifest generator ---- - -async function generateManifest(assets: AssetEntry[]): Promise { - await mkdir(path.dirname(MANIFEST_PATH), { recursive: true }); - - const keys = assets.map((a) => ` | "${a.key}"`).join("\n"); - - const entries = assets - .map((a) => { - const fields: string[] = []; - if (a.avif) fields.push(`avif: "${a.avif}"`); - if (a.webp) fields.push(`webp: "${a.webp}"`); - if (a.svg) fields.push(`svg: "${a.svg}"`); - if (a.width != null) fields.push(`width: ${a.width}`); - if (a.height != null) fields.push(`height: ${a.height}`); - return ` "${a.key}": { ${fields.join(", ")} }`; - }) - .join(",\n"); - - const code = `/** - * AUTO-GENERATED by scripts/assets-build.ts — do not edit manually. - * Run \`npm run assets:build\` to regenerate. - */ - -export type AssetKey = -${keys}; - -export interface AssetMeta { - avif?: string; - webp?: string; - svg?: string; - width?: number; - height?: number; -} - -export const assets: Record = { -${entries}, -}; - -/** - * Get the best available source URL for an asset. - * Preference: avif > webp > svg. - */ -export function assetSrc(key: AssetKey): string { - const a = assets[key]; - return a.avif ?? a.webp ?? a.svg ?? ""; -} -`; - - await writeFile(MANIFEST_PATH, code, "utf-8"); -} - -// ---- CLI entry ---- - -if (process.argv[1]?.endsWith("assets-build.ts") || process.argv[1]?.endsWith("assets-build")) { - buildAssets().then((result) => { - for (const w of result.warnings) { - console.warn(`⚠ ${w}`); - } - for (const e of result.errors) { - console.error(`✗ ${e}`); - } - console.log( - `✓ ${result.assets.length} assets built (${result.errors.length} errors, ${result.warnings.length} warnings)` - ); - if (result.errors.length > 0) { - process.exit(1); - } - }); -} diff --git a/scripts/assets-check.ts b/scripts/assets-check.ts deleted file mode 100644 index 4937950..0000000 --- a/scripts/assets-check.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Asset budget checker. - * - * Validates that all optimized assets meet size/dimension budgets. - * Run via: npx tsx scripts/assets-check.ts - * Used in CI to prevent regressions. - */ -import { stat } from "fs/promises"; -import path from "path"; -import fg from "fast-glob"; -import sharp from "sharp"; - -const OUT_DIR = "public/assets/ui"; - -const MAX_FILE_SIZE = 300 * 1024; // 300KB -const MAX_DIMENSION = 2000; -const MAX_PIXELS = 4_000_000; -const PIXEL_ALLOWLIST: string[] = []; - -export interface CheckResult { - total: number; - warnings: string[]; - errors: string[]; -} - -export async function checkAssets(): Promise { - const warnings: string[] = []; - const errors: string[] = []; - - const files = await fg("**/*", { cwd: OUT_DIR }); - files.sort(); - - for (const file of files) { - const filePath = path.join(OUT_DIR, file); - const stats = await stat(filePath); - - // Size budget - if (stats.size > MAX_FILE_SIZE) { - errors.push( - `${file}: ${(stats.size / 1024).toFixed(1)}KB exceeds ${MAX_FILE_SIZE / 1024}KB limit` - ); - } - - // Dimension checks for raster images - const ext = path.extname(file).toLowerCase(); - if (ext === ".avif" || ext === ".webp" || ext === ".png") { - try { - const meta = await sharp(filePath).metadata(); - const w = meta.width ?? 0; - const h = meta.height ?? 0; - - if (w > MAX_DIMENSION || h > MAX_DIMENSION) { - warnings.push(`${file}: ${w}x${h} exceeds ${MAX_DIMENSION}px`); - } - - const basename = path.basename(file, ext); - if (w * h > MAX_PIXELS && !PIXEL_ALLOWLIST.includes(basename)) { - errors.push(`${file}: ${w * h} pixels exceeds ${MAX_PIXELS} limit`); - } - } catch { - // Non-image file, skip dimension check - } - } - } - - return { total: files.length, warnings, errors }; -} - -// CLI entry -if (process.argv[1]?.endsWith("assets-check.ts") || process.argv[1]?.endsWith("assets-check")) { - checkAssets().then((result) => { - for (const w of result.warnings) { - console.warn(`⚠ ${w}`); - } - for (const e of result.errors) { - console.error(`✗ ${e}`); - } - console.log(`✓ ${result.total} assets checked (${result.errors.length} errors, ${result.warnings.length} warnings)`); - if (result.errors.length > 0) { - process.exit(1); - } - }); -} diff --git a/src/__tests__/assets-pipeline.test.ts b/src/__tests__/assets-pipeline.test.ts deleted file mode 100644 index fdbcb14..0000000 --- a/src/__tests__/assets-pipeline.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Tests for the asset optimization pipeline. - * - * Verifies: - * - Manifest generation with stable keys - * - Budget enforcement (file size, dimensions, pixels) - * - Deterministic output (running twice yields same manifest) - * - @2x → @1x downscaling - * - SVG optimization (metadata stripped) - */ -import { describe, it, expect, beforeAll } from "vitest"; -import { existsSync, readFileSync, statSync } from "fs"; -import path from "path"; - -// Build assets before tests (the pipeline should already have been run) -// If not, tests will verify against whatever is in public/assets/ui - -const OUT_DIR = "public/assets/ui"; -const MANIFEST_PATH = "src/ui/assets/manifest.ts"; -const MAX_FILE_SIZE = 300 * 1024; - -describe("Asset Pipeline", () => { - let manifestContent: string; - let manifestModule: any; - - beforeAll(async () => { - // Build assets fresh - const { buildAssets } = await import("../../scripts/assets-build"); - const result = await buildAssets(); - expect(result.errors).toHaveLength(0); - - manifestContent = readFileSync(MANIFEST_PATH, "utf-8"); - manifestModule = await import("@/ui/assets/manifest"); - }); - - describe("manifest generation", () => { - it("generates a valid TypeScript manifest file", () => { - expect(existsSync(MANIFEST_PATH)).toBe(true); - expect(manifestContent).toContain("export type AssetKey"); - expect(manifestContent).toContain("export const assets"); - expect(manifestContent).toContain("export function assetSrc"); - }); - - it("produces stable keys sorted alphabetically", () => { - const keyMatches = manifestContent.match(/\| "([^"]+)"/g); - expect(keyMatches).toBeTruthy(); - const keys = keyMatches!.map((m) => m.replace('| "', "").replace('"', "")); - const sorted = [...keys].sort(); - expect(keys).toEqual(sorted); - }); - - it("includes all expected fixture assets", () => { - const { assets } = manifestModule; - expect(assets["btn-primary"]).toBeDefined(); - expect(assets["icon-phone-off"]).toBeDefined(); - expect(assets["icon-check"]).toBeDefined(); - expect(assets["icon-session"]).toBeDefined(); - }); - - it("raster assets have avif and webp paths", () => { - const { assets } = manifestModule; - const btn = assets["btn-primary"]; - expect(btn.avif).toMatch(/\.avif$/); - expect(btn.webp).toMatch(/\.webp$/); - expect(btn.width).toBeGreaterThan(0); - expect(btn.height).toBeGreaterThan(0); - }); - - it("SVG assets have svg path", () => { - const { assets } = manifestModule; - const icon = assets["icon-phone-off"]; - expect(icon.svg).toMatch(/\.svg$/); - expect(icon.avif).toBeUndefined(); - expect(icon.webp).toBeUndefined(); - }); - - it("assetSrc prefers avif for raster assets", () => { - const { assetSrc } = manifestModule; - expect(assetSrc("btn-primary")).toMatch(/\.avif$/); - }); - - it("assetSrc returns svg for SVG-only assets", () => { - const { assetSrc } = manifestModule; - expect(assetSrc("icon-check")).toMatch(/\.svg$/); - }); - }); - - describe("@2x downscaling", () => { - it("generates @1x variant from @2x input", () => { - const { assets } = manifestModule; - expect(assets["btn-primary@2x"]).toBeDefined(); - expect(assets["btn-primary@1x"]).toBeDefined(); - }); - - it("@1x dimensions are half of @2x", () => { - const { assets } = manifestModule; - const x2 = assets["btn-primary@2x"]; - const x1 = assets["btn-primary@1x"]; - expect(x1.width).toBe(Math.round(x2.width / 2)); - expect(x1.height).toBe(Math.round(x2.height / 2)); - }); - }); - - describe("SVG optimization", () => { - it("strips metadata and comments from SVGs", () => { - const svgPath = path.join(OUT_DIR, "icon-phone-off.svg"); - const content = readFileSync(svgPath, "utf-8"); - expect(content).not.toContain(""); - expect(content).not.toContain("