From 864b9226fcb2efae23a3924ed7e547d67e48534d Mon Sep 17 00:00:00 2001 From: fristyxd Date: Fri, 11 Sep 2026 23:54:59 +0300 Subject: [PATCH 1/2] desloppify: code health overhaul plus abuse hardening (140 green, strict 87.2) Honest baseline after removing 45 gaming suppressions. Structural: lib/game plus root hooks extraction, CORS origin echo, DEV_ORIGINS layering. Fixes: error-cause binding, discord 5xx split, null-cache guard, co-located tests, rejecting-fetch coverage, naming and typing cleanup, socket controller, cache bound, cascade helpers, latch resets. Hardening: per-IP rate limit on API routes, deterministic-miss UX, retry cooldown plus cap. Suite 140 green, build plus lint clean. --- .gitignore | 3 + README.md | 3 + app/api/discord-app/[id]/route.test.ts | 47 + app/api/discord-app/[id]/route.ts | 57 +- app/api/game/[id]/route.test.ts | 53 + app/api/game/[id]/route.ts | 71 +- app/error.test.tsx | 13 + app/layout.test.tsx | 27 + app/loading.test.tsx | 11 + app/not-found.test.tsx | 16 + app/page.test.tsx | 51 + app/page.tsx | 2 +- components/contact-section.test.tsx | 13 + components/contact-section.tsx | 4 +- components/copy-email.test.tsx | 22 + components/discord-card.test.tsx | 21 + components/discord-card.tsx | 256 +---- components/game-profile-modal.test.tsx | 81 ++ components/game-profile-modal.tsx | 436 +------- components/game-profile-sections.test.tsx | 59 ++ components/game-profile-sections.tsx | 262 +++++ components/language-switcher.test.tsx | 29 + components/language-switcher.tsx | 4 +- components/projects-section.test.tsx | 22 + components/projects-section.tsx | 4 +- components/section-heading.test.tsx | 17 + components/site-footer.test.tsx | 15 + components/site-header.test.tsx | 14 + components/site-header.tsx | 2 +- docker-compose.yml | 8 + hooks/use-game-profile.test.tsx | 75 ++ hooks/use-game-profile.ts | 120 +++ hooks/use-lanyard.test.tsx | 19 + hooks/use-lanyard.ts | 207 ++++ lib/api-cors.ts | 19 - lib/api-route.test.ts | 53 + lib/api-route.ts | 97 ++ lib/client-fetch.test.ts | 34 + lib/client-fetch.ts | 15 + lib/game-profile.ts | 635 ------------ lib/game/detectables.test.ts | 77 ++ lib/game/detectables.ts | 37 + lib/game/discord-app.test.ts | 49 + lib/game/discord-app.ts | 95 ++ lib/game/game-format.test.ts | 148 +++ lib/game/game-format.ts | 211 ++++ lib/game/game-profile-orchestration.test.ts | 91 ++ lib/game/game-profile-types.ts | 141 +++ lib/game/game-profile.test.ts | 94 ++ lib/game/game-profile.ts | 287 +++++ lib/game/igdb.test.ts | 75 ++ lib/game/igdb.ts | 139 +++ lib/game/itad.test.ts | 69 ++ lib/game/itad.ts | 48 + lib/game/lanyard.test.ts | 109 ++ lib/{ => game}/lanyard.ts | 90 +- lib/game/steam.test.ts | 108 ++ lib/game/steam.ts | 58 ++ lib/gitea.test.ts | 59 ++ lib/gitea.ts | 8 +- lib/i18n/dictionaries/de.json | 3 +- lib/i18n/dictionaries/en.json | 3 +- lib/i18n/dictionaries/tr.json | 3 +- lib/i18n/get-dictionary.test.ts | 27 + lib/i18n/get-dictionary.ts | 2 +- lib/i18n/get-locale.test.ts | 25 + lib/i18n/locales.test.ts | 36 + lib/i18n/set-locale-cookie.ts | 16 - lib/log.test.ts | 23 + lib/log.ts | 6 + lib/site.ts | 4 + package.json | 9 +- pnpm-lock.yaml | 1034 +++++++++++++++++++ proxy.test.ts | 98 ++ proxy.ts | 81 +- scorecard.png | Bin 0 -> 89521 bytes test/fixtures.ts | 66 ++ vitest.config.ts | 12 + 78 files changed, 4908 insertions(+), 1430 deletions(-) create mode 100644 app/api/discord-app/[id]/route.test.ts create mode 100644 app/api/game/[id]/route.test.ts create mode 100644 app/error.test.tsx create mode 100644 app/layout.test.tsx create mode 100644 app/loading.test.tsx create mode 100644 app/not-found.test.tsx create mode 100644 app/page.test.tsx create mode 100644 components/contact-section.test.tsx create mode 100644 components/copy-email.test.tsx create mode 100644 components/discord-card.test.tsx create mode 100644 components/game-profile-modal.test.tsx create mode 100644 components/game-profile-sections.test.tsx create mode 100644 components/game-profile-sections.tsx create mode 100644 components/language-switcher.test.tsx create mode 100644 components/projects-section.test.tsx create mode 100644 components/section-heading.test.tsx create mode 100644 components/site-footer.test.tsx create mode 100644 components/site-header.test.tsx create mode 100644 docker-compose.yml create mode 100644 hooks/use-game-profile.test.tsx create mode 100644 hooks/use-game-profile.ts create mode 100644 hooks/use-lanyard.test.tsx create mode 100644 hooks/use-lanyard.ts delete mode 100644 lib/api-cors.ts create mode 100644 lib/api-route.test.ts create mode 100644 lib/api-route.ts create mode 100644 lib/client-fetch.test.ts create mode 100644 lib/client-fetch.ts delete mode 100644 lib/game-profile.ts create mode 100644 lib/game/detectables.test.ts create mode 100644 lib/game/detectables.ts create mode 100644 lib/game/discord-app.test.ts create mode 100644 lib/game/discord-app.ts create mode 100644 lib/game/game-format.test.ts create mode 100644 lib/game/game-format.ts create mode 100644 lib/game/game-profile-orchestration.test.ts create mode 100644 lib/game/game-profile-types.ts create mode 100644 lib/game/game-profile.test.ts create mode 100644 lib/game/game-profile.ts create mode 100644 lib/game/igdb.test.ts create mode 100644 lib/game/igdb.ts create mode 100644 lib/game/itad.test.ts create mode 100644 lib/game/itad.ts create mode 100644 lib/game/lanyard.test.ts rename lib/{ => game}/lanyard.ts (57%) create mode 100644 lib/game/steam.test.ts create mode 100644 lib/game/steam.ts create mode 100644 lib/gitea.test.ts create mode 100644 lib/i18n/get-dictionary.test.ts create mode 100644 lib/i18n/get-locale.test.ts create mode 100644 lib/i18n/locales.test.ts delete mode 100644 lib/i18n/set-locale-cookie.ts create mode 100644 lib/log.test.ts create mode 100644 lib/log.ts create mode 100644 proxy.test.ts create mode 100644 scorecard.png create mode 100644 test/fixtures.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index 012d0dd..0ec1a16 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ next-env.d.ts # local code intelligence index .codegraph/ + +# desloppify local state (scan state, review packets, score snapshots) +.desloppify/ diff --git a/README.md b/README.md index b29e6c1..fcc8daf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # fristy.net +![Code Health](scorecard.png) + My personal corner of the internet — projects, live Discord status, and ways to reach me. **Live:** https://fristy.net @@ -29,6 +31,7 @@ pnpm dev # http://localhost:3000 | `pnpm dev` | Start the dev server | | `pnpm build` | Production build | | `pnpm start` | Serve the production build | +| `pnpm test` | Run the Vitest suite | | `pnpm lint` | Run ESLint | | `pnpm format` | Format everything with Prettier | | `pnpm format:check` | Fail if anything is unformatted | diff --git a/app/api/discord-app/[id]/route.test.ts b/app/api/discord-app/[id]/route.test.ts new file mode 100644 index 0000000..9fa1f72 --- /dev/null +++ b/app/api/discord-app/[id]/route.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GET } from "@/app/api/discord-app/[id]/route"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function contextWith(id: string) { + return { params: Promise.resolve({ id }) }; +} + +describe("GET /api/discord-app/[id]", () => { + it("returns 400 for non-numeric ids", async () => { + const response = await GET( + new Request("https://fristy.net/api/discord-app/abc"), + contextWith("abc"), + ); + expect(response.status).toBe(400); + }); + + it("returns 404 when the app is unknown", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 404 })), + ); + const response = await GET( + new Request("https://fristy.net/api/discord-app/123"), + contextWith("123"), + ); + expect(response.status).toBe(404); + }); + + it("returns 502 when upstream networking fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + const response = await GET( + new Request("https://fristy.net/api/discord-app/123"), + contextWith("123"), + ); + expect(response.status).toBe(502); + }); +}); diff --git a/app/api/discord-app/[id]/route.ts b/app/api/discord-app/[id]/route.ts index e70cace..dc7a97f 100644 --- a/app/api/discord-app/[id]/route.ts +++ b/app/api/discord-app/[id]/route.ts @@ -1,37 +1,32 @@ -import { NextResponse } from "next/server"; -import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-cors"; -import { getDiscordAppInfo } from "@/lib/lanyard"; +import { getDiscordAppInfo } from "@/lib/game/discord-app"; +import { + invalidIdResponse, + jsonErrorWithCors, + jsonWithCors, + parseAppId, + requestOrigin, +} from "@/lib/api-route"; +import { warnDev } from "@/lib/log"; +import type { NextResponse } from "next/server"; -export async function OPTIONS(request: Request) { - if (!isAllowedOrigin(request)) { - return forbidden(); +export async function GET( + request: Request, + context: { params: Promise<{ id: string }> }, +): Promise { + const origin = requestOrigin(request); + const id = await parseAppId(context); + if (!id) { + return invalidIdResponse(origin); } - return new NextResponse(null, { - status: 204, - headers: { - ...corsHeaders(), - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Max-Age": "86400", - }, - }); -} - -export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { - if (!isAllowedOrigin(request)) { - return forbidden(); + let info; + try { + info = await getDiscordAppInfo(id); + } catch (error) { + warnDev(`discord-app upstream failed: ${id} -> ${error instanceof Error ? error.message : error}`); + return jsonErrorWithCors({ error: "Upstream unavailable" }, 502, 60, origin); } - const { id } = await context.params; - if (!/^\d+$/.test(id)) { - return NextResponse.json({ error: "Invalid application id" }, { status: 400 }); - } - const info = await getDiscordAppInfo(id); if (!info) { - return NextResponse.json({ error: "Application not found" }, { status: 404 }); + return jsonErrorWithCors({ error: "Application not found" }, 404, 60, origin); } - return NextResponse.json(info, { - headers: { - "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=86400", - ...corsHeaders(), - }, - }); + return jsonWithCors(info, 86400, origin); } diff --git a/app/api/game/[id]/route.test.ts b/app/api/game/[id]/route.test.ts new file mode 100644 index 0000000..dfcd46f --- /dev/null +++ b/app/api/game/[id]/route.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GET } from "@/app/api/game/[id]/route"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function contextWith(id: string) { + return { params: Promise.resolve({ id }) }; +} + +function requestWith(url: string) { + return new Request(url); +} + +describe("GET /api/game/[id]", () => { + it("returns 400 for non-numeric ids", async () => { + const response = await GET( + requestWith("https://fristy.net/api/game/abc"), + contextWith("abc"), + ); + expect(response.status).toBe(400); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + }); + + it("returns 404 when no profile resolves", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 404 })), + ); + const response = await GET( + requestWith("https://fristy.net/api/game/123"), + contextWith("123"), + ); + expect(response.status).toBe(404); + }); + + it("returns 502 when upstream networking fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + const response = await GET( + requestWith("https://fristy.net/api/game/123"), + contextWith("123"), + ); + expect(response.status).toBe(502); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + }); +}); diff --git a/app/api/game/[id]/route.ts b/app/api/game/[id]/route.ts index 0d94c8c..914563b 100644 --- a/app/api/game/[id]/route.ts +++ b/app/api/game/[id]/route.ts @@ -1,44 +1,37 @@ -import { NextResponse } from "next/server"; -import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-cors"; -import { getGameProfile } from "@/lib/game-profile"; +import { + invalidIdResponse, + jsonErrorWithCors, + jsonWithCors, + parseAppId, + requestOrigin, +} from "@/lib/api-route"; +import { resolveGameLocale } from "@/lib/game/game-format"; +import { getGameProfile } from "@/lib/game/game-profile"; +import { warnDev } from "@/lib/log"; +import type { NextResponse } from "next/server"; -export async function OPTIONS(request: Request) { - if (!isAllowedOrigin(request)) { - return forbidden(); - } - return new NextResponse(null, { - status: 204, - headers: { - ...corsHeaders(), - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Max-Age": "86400", - }, - }); -} - -export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { - if (!isAllowedOrigin(request)) { - return forbidden(); - } - const { id } = await context.params; - if (!/^\d+$/.test(id)) { - return NextResponse.json({ error: "Invalid application id" }, { status: 400 }); +export async function GET( + request: Request, + context: { params: Promise<{ id: string }> }, +): Promise { + const origin = requestOrigin(request); + const id = await parseAppId(context); + if (!id) { + return invalidIdResponse(origin); } const { searchParams } = new URL(request.url); - const country = /^[A-Z]{2}$/.test(searchParams.get("country") ?? "") - ? (searchParams.get("country") as string) - : "TR"; - const lang = ["tr", "en", "de"].includes(searchParams.get("lang") ?? "") - ? (searchParams.get("lang") as string) - : "en"; - const profile = await getGameProfile(id, country, lang); - if (!profile) { - return NextResponse.json({ error: "Game not found" }, { status: 404 }); + const countryRaw = searchParams.get("country") ?? ""; + const country = /^[A-Z]{2}$/.test(countryRaw) ? countryRaw : "TR"; + const { lang } = resolveGameLocale(searchParams.get("lang") ?? ""); + let profile; + try { + profile = await getGameProfile(id, lang, country); + } catch (error) { + warnDev(`game upstream failed: ${id} -> ${error instanceof Error ? error.message : error}`); + return jsonErrorWithCors({ error: "Upstream unavailable" }, 502, 60, origin); } - return NextResponse.json(profile, { - headers: { - "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", - ...corsHeaders(), - }, - }); + if (!profile) { + return jsonErrorWithCors({ error: "Game not found" }, 404, 60, origin); + } + return jsonWithCors(profile, 3600, origin); } diff --git a/app/error.test.tsx b/app/error.test.tsx new file mode 100644 index 0000000..ae15684 --- /dev/null +++ b/app/error.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import ErrorPage from "@/app/error"; + +describe("Error page", () => { + it("renders and retries on click", () => { + const reset = vi.fn(); + render(); + expect(screen.getByText("// 500")).toBeDefined(); + fireEvent.click(screen.getByRole("button")); + expect(reset).toHaveBeenCalledOnce(); + }); +}); diff --git a/app/layout.test.tsx b/app/layout.test.tsx new file mode 100644 index 0000000..85bb766 --- /dev/null +++ b/app/layout.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +vi.mock("next/font/google", () => ({ + Cascadia_Mono: () => ({ variable: "font-test" }), +})); + +vi.mock("@/lib/i18n/get-locale", () => ({ + getLocale: async () => "en" as const, +})); + +import RootLayout, { generateMetadata } from "@/app/layout"; + +describe("generateMetadata", () => { + it("builds site metadata for the locale", async () => { + const meta = await generateMetadata(); + expect(meta.description).toBeTruthy(); + }); +}); + +describe("RootLayout", () => { + it("renders children inside the locale shell", async () => { + render(await RootLayout({ children:

child

, params: Promise.resolve({}) })); + expect(screen.getByText("child")).toBeDefined(); + expect(document.documentElement.lang).toBe("en"); + }); +}); diff --git a/app/loading.test.tsx b/app/loading.test.tsx new file mode 100644 index 0000000..196319f --- /dev/null +++ b/app/loading.test.tsx @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import Loading from "@/app/loading"; + +describe("Loading", () => { + it("renders a busy skeleton", () => { + const { container } = render(); + expect(screen.getByRole("generic", { busy: true })).toBeDefined(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); +}); diff --git a/app/not-found.test.tsx b/app/not-found.test.tsx new file mode 100644 index 0000000..bb20ba4 --- /dev/null +++ b/app/not-found.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import NotFound from "@/app/not-found"; + +vi.mock("@/lib/i18n/get-locale", () => ({ + getLocale: async () => "en" as const, +})); + +describe("NotFound page", () => { + it("renders the 404 block with a home link", async () => { + render(await NotFound()); + expect(screen.getByText("404")).toBeDefined(); + const home = screen.getByRole("link", { name: /home/i }); + expect(home.getAttribute("href")).toBe("/"); + }); +}); diff --git a/app/page.test.tsx b/app/page.test.tsx new file mode 100644 index 0000000..1ccef1e --- /dev/null +++ b/app/page.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import Home from "@/app/page"; + +vi.mock("@/lib/i18n/get-locale", () => ({ + getLocale: async () => "en" as const, +})); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Home page", () => { + it("renders hero, sections, and footer", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (String(url).includes("git.fristy.net")) { + return { ok: true, json: async () => [] } as Response; + } + return { + ok: true, + json: async () => ({ + success: true, + data: { + discord_user: { + id: "1", + username: "testuser", + global_name: "Test User", + display_name: "Test User", + discriminator: "0", + avatar: null, + bot: false, + }, + activities: [], + discord_status: "offline", + active_on_discord_desktop: false, + active_on_discord_mobile: false, + active_on_discord_web: false, + listening_to_spotify: false, + spotify: null, + }, + }), + } as Response; + }), + ); + render(await Home()); + expect(screen.getByRole("heading", { level: 1 }).textContent).toBe("fristy"); + expect(screen.getByText("@testuser")).toBeDefined(); + }); +}); diff --git a/app/page.tsx b/app/page.tsx index a82c3e9..c623c35 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,7 +7,7 @@ import { SiteHeader } from "@/components/site-header"; import { getRepos } from "@/lib/gitea"; import { getDictionary } from "@/lib/i18n/get-dictionary"; import { getLocale } from "@/lib/i18n/get-locale"; -import { DISCORD_ID, discordProfileUrl, getLanyard } from "@/lib/lanyard"; +import { DISCORD_ID, discordProfileUrl, getLanyard } from "@/lib/game/lanyard"; export default async function Home() { const locale = await getLocale(); diff --git a/components/contact-section.test.tsx b/components/contact-section.test.tsx new file mode 100644 index 0000000..763e7d9 --- /dev/null +++ b/components/contact-section.test.tsx @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { ContactSection } from "@/components/contact-section"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; + +describe("ContactSection", () => { + it("renders contact channels", () => { + const dict = getDictionary("en"); + render(); + expect(screen.getByText(dict.contact.title)).toBeDefined(); + cleanup(); + }); +}); diff --git a/components/contact-section.tsx b/components/contact-section.tsx index 52b36c7..304744f 100644 --- a/components/contact-section.tsx +++ b/components/contact-section.tsx @@ -8,9 +8,9 @@ import { SPOTIFY_URL, STEAM_URL, } from "@/lib/site"; -import { CopyEmail } from "./copy-email"; -import { SectionHeading } from "./section-heading"; +import { CopyEmail } from "@/components/copy-email"; +import { SectionHeading } from "@/components/section-heading"; export function ContactSection({ dict }: { dict: Dictionary["contact"] }) { const entries = [ { label: dict.github, sub: "@fristyxd", href: GITHUB_URL, icon: "/icons/github.svg" }, diff --git a/components/copy-email.test.tsx b/components/copy-email.test.tsx new file mode 100644 index 0000000..e1006c4 --- /dev/null +++ b/components/copy-email.test.tsx @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { CopyEmail } from "@/components/copy-email"; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("CopyEmail", () => { + it("copies on click and shows feedback", async () => { + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + render(); + fireEvent.click(screen.getByRole("button")); + expect(await screen.findByText("Copied")).toBeDefined(); + expect(writeText).toHaveBeenCalledWith("a@b.c"); + }); +}); diff --git a/components/discord-card.test.tsx b/components/discord-card.test.tsx new file mode 100644 index 0000000..9ec04d7 --- /dev/null +++ b/components/discord-card.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DiscordCard } from "@/components/discord-card"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; +import { lanyardFixture } from "@/test/fixtures"; + +describe("DiscordCard", () => { + it("renders presence with quiet state", () => { + const dict = getDictionary("en"); + render(); + expect(screen.getByText("Test User")).toBeDefined(); + expect(screen.getByText("@testuser")).toBeDefined(); + expect(screen.getByText(dict.discord.noActivity)).toBeDefined(); + }); + + it("renders the offline fallback without data", () => { + const dict = getDictionary("en"); + render(); + expect(screen.getByText(dict.discord.offline)).toBeDefined(); + }); +}); diff --git a/components/discord-card.tsx b/components/discord-card.tsx index 5a1754d..c6c7291 100644 --- a/components/discord-card.tsx +++ b/components/discord-card.tsx @@ -4,20 +4,20 @@ import Image from "next/image"; import { useEffect, useState } from "react"; import { GameProfileModal } from "@/components/game-profile-modal"; import type { Dictionary } from "@/lib/i18n/get-dictionary"; +import { useLanyard } from "@/hooks/use-lanyard"; import { activityImageUrl, - DISCORD_ID, discordAvatarUrl, - discordGameUrl, formatSpotifyList, - LANYARD_WS, - lanyardUrl, spotifyTrackUrl, - type DiscordAppInfo, type DiscordStatus, type LanyardActivity, type LanyardData, -} from "@/lib/lanyard"; +} from "@/lib/game/lanyard"; +import { discordGameUrl, type DiscordAppInfo } from "@/lib/game/discord-app"; +import { fetchJson } from "@/lib/client-fetch"; +import { formatElapsed } from "@/lib/game/game-format"; +import type { Locale } from "@/lib/i18n/locales"; const STATUS_DOT: Record = { online: "bg-emerald-400", @@ -26,15 +26,13 @@ const STATUS_DOT: Record = { offline: "bg-zinc-500", }; -const WS_OPEN_TIMEOUT_MS = 10_000; -const RECONNECT_BASE_MS = 2_000; -const RECONNECT_MAX_MS = 30_000; -const SAFETY_POLL_MS = 60_000; +const MS_PER_SECOND = 1000; +const SECONDS_PER_MINUTE = 60; function formatTrackTime(ms: number): string { - const totalSeconds = Math.max(0, Math.floor(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; + const totalSeconds = Math.max(0, Math.floor(ms / MS_PER_SECOND)); + const minutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE); + const seconds = totalSeconds % SECONDS_PER_MINUTE; return `${minutes}:${seconds.toString().padStart(2, "0")}`; } @@ -47,11 +45,12 @@ function SpotifyProgress({ start, end }: { start: number; end: number }) { }, []); const total = Math.max(1, end - start); + const elapsed = Math.min(Math.max(0, now - start), total); const ratio = Math.min(1, Math.max(0, (now - start) / total)); const percent = Math.round(ratio * 100); return (
- {formatTrackTime(now - start)} + {formatTrackTime(elapsed)}
0) { - return `${hours}s ${minutes}dk`; - } - if (minutes > 0) { - return `${minutes}dk`; - } - return "<1dk"; - } - if (lang === "de") { - if (hours > 0) { - return `${hours} Std. ${minutes} Min.`; - } - if (minutes > 0) { - return `${minutes} Min.`; - } - return "<1 Min."; - } - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - if (minutes > 0) { - return `${minutes}m`; - } - return "<1m"; -} +const DISCORD_APP_CACHE_LIMIT = 50; const discordAppCache = new Map(); +export function _resetDiscordAppCache(): void { + discordAppCache.clear(); +} + +function cacheDiscordApp(id: string, info: DiscordAppInfo): void { + if (!discordAppCache.has(id) && discordAppCache.size >= DISCORD_APP_CACHE_LIMIT) { + const oldest = discordAppCache.keys().next(); + if (!oldest.done) { + discordAppCache.delete(oldest.value); + } + } + discordAppCache.set(id, info); +} + function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo | null { const [info, setInfo] = useState(() => applicationId ? (discordAppCache.get(applicationId) ?? null) : null, ); + const [prevId, setPrevId] = useState(applicationId); + if (prevId !== applicationId) { + setPrevId(applicationId); + setInfo(applicationId ? (discordAppCache.get(applicationId) ?? null) : null); + } useEffect(() => { if (!applicationId || discordAppCache.has(applicationId)) { @@ -111,16 +100,12 @@ function useDiscordApp(applicationId: string | null | undefined): DiscordAppInfo const id = applicationId; let cancelled = false; async function load(): Promise { - try { - const response = await fetch(`/api/discord-app/${id}`); - const json = (await response.json()) as DiscordAppInfo | { error: string }; - const resolved = response.ok && "iconUrl" in json ? (json as DiscordAppInfo) : null; - discordAppCache.set(id, resolved); - if (!cancelled) { - setInfo(resolved); - } - } catch { - discordAppCache.set(id, null); + const resolved = await fetchJson(`/api/discord-app/${id}`); + if (resolved) { + cacheDiscordApp(id, resolved); + } + if (!cancelled) { + setInfo(resolved); } } void load(); @@ -139,13 +124,13 @@ function ActivityRow({ }: { activity: LanyardActivity; dict: Dictionary["discord"]; - locale: string; + locale: Locale; }) { const appInfo = useDiscordApp(activity.application_id); const [profileOpen, setProfileOpen] = useState(false); const [heroSeed, setHeroSeed] = useState(0); - const large = activityImageUrl(activity, false) ?? appInfo?.iconUrl ?? null; - const small = activityImageUrl(activity, true); + const large = activityImageUrl(activity, "large") ?? appInfo?.iconUrl ?? null; + const small = activityImageUrl(activity, "small"); const gameUrl = activity.application_id ? (appInfo?.gameUrl ?? discordGameUrl(activity.application_id)) : null; @@ -229,162 +214,9 @@ export function DiscordCard({ }: { initial: LanyardData | null; dict: Dictionary["discord"]; - locale: string; + locale: Locale; }) { - const [data, setData] = useState(initial); - - useEffect(() => { - let disposed = false; - let socket: WebSocket | null = null; - let heartbeatTimer: number | undefined; - let reconnectTimer: number | undefined; - let reconnectDelay = RECONNECT_BASE_MS; - - async function pollOnce(): Promise { - try { - const response = await fetch(lanyardUrl(), { cache: "no-store" }); - if (!response.ok) { - return; - } - const json = (await response.json()) as { - success: boolean; - data: LanyardData; - }; - if (json.success && !disposed) { - setData(json.data); - } - } catch { - return; - } - } - - function scheduleReconnect(): void { - if (disposed) { - return; - } - window.clearTimeout(reconnectTimer); - reconnectTimer = window.setTimeout(() => { - reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS); - connect(); - }, reconnectDelay); - } - - function dropSocket(current: WebSocket): void { - if (socket === current) { - socket = null; - } - try { - current.close(); - } catch { - socket = null; - } - } - - function connect(): void { - if (disposed) { - return; - } - let current: WebSocket; - try { - current = new WebSocket(LANYARD_WS); - } catch { - scheduleReconnect(); - return; - } - socket = current; - let settled = false; - - const openTimeout = window.setTimeout(() => { - if (!settled) { - settled = true; - dropSocket(current); - if (!disposed) { - void pollOnce(); - scheduleReconnect(); - } - } - }, WS_OPEN_TIMEOUT_MS); - - const handleDrop = (): void => { - if (settled && socket !== current) { - return; - } - settled = true; - window.clearTimeout(openTimeout); - window.clearInterval(heartbeatTimer); - dropSocket(current); - if (!disposed) { - void pollOnce(); - scheduleReconnect(); - } - }; - - current.onmessage = (event: MessageEvent) => { - let message: { op: number; t?: string; d?: unknown }; - try { - message = JSON.parse(event.data as string) as { - op: number; - t?: string; - d?: unknown; - }; - } catch { - return; - } - if (message.op === 1) { - settled = true; - window.clearTimeout(openTimeout); - reconnectDelay = RECONNECT_BASE_MS; - const interval = - (message.d as { heartbeat_interval?: number } | undefined) - ?.heartbeat_interval ?? 30_000; - window.clearInterval(heartbeatTimer); - heartbeatTimer = window.setInterval(() => { - try { - current.send(JSON.stringify({ op: 3 })); - } catch { - handleDrop(); - } - }, interval); - current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } })); - } else if ( - message.op === 0 && - (message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE") - ) { - if (!disposed) { - setData(message.d as LanyardData); - } - } - }; - current.onerror = handleDrop; - current.onclose = handleDrop; - } - - connect(); - const safetyTimer = window.setInterval(() => { - if (!socket || socket.readyState !== WebSocket.OPEN) { - void pollOnce(); - } - }, SAFETY_POLL_MS); - - return () => { - disposed = true; - window.clearTimeout(reconnectTimer); - window.clearInterval(safetyTimer); - window.clearInterval(heartbeatTimer); - if (socket) { - const current = socket; - socket = null; - current.onclose = null; - current.onerror = null; - current.onmessage = null; - try { - current.close(); - } catch { - socket = null; - } - } - }; - }, []); + const data = useLanyard(initial); if (!data) { return ( diff --git a/components/game-profile-modal.test.tsx b/components/game-profile-modal.test.tsx new file mode 100644 index 0000000..76deba6 --- /dev/null +++ b/components/game-profile-modal.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { GameProfileModal } from "@/components/game-profile-modal"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; +import { profileFixture } from "@/test/fixtures"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("GameProfileModal", () => { + it("loads and renders the game profile", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => profileFixture() }) as Response), + ); + const dict = getDictionary("en"); + render( + {}} + />, + ); + expect(await screen.findByText("Test Game")).toBeDefined(); + expect(screen.getByText("A test game")).toBeDefined(); + }); + + it("shows no-game-data without retry on deterministic miss", async () => { + const deterministic = profileFixture({ + steamId: null, + steamUrl: null, + sources: { igdb: false, steam: false, itad: false }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => deterministic }) as Response), + ); + const dict = getDictionary("en"); + render( + {}} + />, + ); + expect(await screen.findByText(dict.discord.gameModal.noGameData)).toBeDefined(); + expect(screen.queryByText(dict.discord.gameModal.retry)).toBeNull(); + }); + + it("shows the error state with retry", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500 }) as Response), + ); + const dict = getDictionary("en"); + render( + {}} + />, + ); + expect(await screen.findByText(dict.discord.gameModal.loadError)).toBeDefined(); + }); +}); diff --git a/components/game-profile-modal.tsx b/components/game-profile-modal.tsx index b7f7bd8..1615458 100644 --- a/components/game-profile-modal.tsx +++ b/components/game-profile-modal.tsx @@ -1,91 +1,12 @@ "use client"; import Image from "next/image"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { GamePricePoint, GameProfile } from "@/lib/game-profile"; -import { steamdbRating } from "@/lib/game-profile"; -import type { Dictionary } from "@/lib/i18n/get-dictionary"; - -type ModalDict = Dictionary["discord"]["gameModal"]; - -const STEAM_SCORE_KEYS: Record = { - "Overwhelmingly Positive": "overwhelminglyPositive", - "Very Positive": "veryPositive", - Positive: "positive", - "Mostly Positive": "mostlyPositive", - Mixed: "mixed", - "Mostly Negative": "mostlyNegative", - Negative: "negative", - "Very Negative": "veryNegative", - "Overwhelmingly Negative": "overwhelminglyNegative", -}; - -const LOCALE_TAGS: Record = { - tr: "tr-TR", - en: "en-US", - de: "de-DE", -}; - -const COUNTRIES: Record = { - tr: "TR", - en: "US", - de: "DE", -}; - -function localizeScore(label: string, dict: ModalDict): string { - const key = STEAM_SCORE_KEYS[label]; - return key ? dict.reviewScores[key] : label; -} - -function formatRatingPercent(value: number, tag: string): string { - try { - return new Intl.NumberFormat(tag, { - style: "percent", - maximumFractionDigits: 2, - }).format(value); - } catch { - return `%${(value * 100).toFixed(2)}`; - } -} - -function formatPrice(point: GamePricePoint, tag: string): string { - try { - return new Intl.NumberFormat(tag, { - style: "currency", - currency: point.currency, - }).format(point.price); - } catch { - return `${point.price} ${point.currency}`; - } -} - -function formatReleaseDate(value: string, tag: string): string { - if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { - const parsed = new Date(`${value}T00:00:00`); - if (!Number.isNaN(parsed.getTime())) { - return parsed.toLocaleDateString(tag, { - day: "numeric", - month: "long", - year: "numeric", - }); - } - } - return value; -} - -function formatCount(value: number | null | undefined, tag: string, unit: string): string { - if (value == null) { - return "—"; - } - return `${value.toLocaleString(tag)} ${unit}`; -} - -function discountPercent(point: GamePricePoint): number | null { - if (point.regular > point.price && point.regular > 0) { - return Math.round((1 - point.price / point.regular) * 100); - } - return null; -} +import { useEffect } from "react"; +import { ModalPrices, ModalReviews, ModalScreenshots } from "@/components/game-profile-sections"; +import { formatReleaseDate, selectGameView, type ModalDict } from "@/lib/game/game-format"; +import { steamdbAppUrl } from "@/lib/game/discord-app"; +import type { Locale } from "@/lib/i18n/locales"; +import { useGameProfile } from "@/hooks/use-game-profile"; export function GameProfileModal({ applicationId, @@ -99,48 +20,26 @@ export function GameProfileModal({ }: { applicationId: string; heroSeed: number; - lang: string; + lang: Locale; iconUrl: string | null; fallbackName: string; fallbackGameUrl: string; dict: ModalDict; onClose: () => void; }) { - const [profile, setProfile] = useState(null); - const [failed, setFailed] = useState(false); - const [attempt, setAttempt] = useState(0); - const [bannerPick, setBannerPick] = useState(null); - const tag = LOCALE_TAGS[lang] ?? "en-US"; - const country = COUNTRIES[lang] ?? "US"; - - useEffect(() => { - let cancelled = false; - async function load(): Promise { - try { - const response = await fetch( - `/api/game/${applicationId}?lang=${lang}&country=${country}`, - ); - if (!response.ok) { - throw new Error(`game api: ${response.status}`); - } - const json = (await response.json()) as GameProfile; - if (!cancelled) { - setProfile(json); - setFailed(false); - } - } catch { - if (!cancelled) { - setFailed(true); - } - } - } - void load(); - return () => { - cancelled = true; - }; - }, [applicationId, lang, country, attempt]); - const partialFail = - !!profile && !profile.sources.igdb && !profile.sources.steam && !profile.sources.itad; + const { + profile, + failed, + localeTag, + hero, + hasDetails, + partialFail, + isDeterministicMiss, + canRetry, + retry, + reset, + setBannerPick, + } = useGameProfile(applicationId, lang, heroSeed); useEffect(() => { function onKey(event: KeyboardEvent): void { @@ -157,72 +56,11 @@ export function GameProfileModal({ }; }, [onClose]); - const gameUrl = profile?.gameUrl ?? fallbackGameUrl; - const name = profile?.name ?? fallbackName; - const best = profile?.itad?.prices ?? null; - const low = profile?.itad?.historyLow ?? null; - const isFree = !!best && best.price === 0; - const hero = useMemo(() => { - if (bannerPick) { - return bannerPick; - } - const shots = profile?.screenshots ?? []; - if (shots.length > 0) { - return shots[heroSeed % shots.length] ?? null; - } - return profile?.artworkUrl ?? null; - }, [profile, heroSeed, bannerPick]); - - const dbRating = - profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null - ? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total) - : null; - const itadReviews = profile?.itad?.reviews ?? []; - const hasDetails = !!( - profile && - (profile.developers.length > 0 || - profile.publishers.length > 0 || - profile.releaseDate || - profile.platforms.length > 0 || - profile.links.length > 0) + const { gameUrl, name, bestPrice, historyLow, isFree } = selectGameView( + profile, + fallbackName, + fallbackGameUrl, ); - const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null; - const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null; - const hasReviews = !!( - profile?.steamReviews || - (dbRating != null && profile?.steamId) || - metascore || - opencritic - ); - - const stripRef = useRef(null); - - useEffect(() => { - const strip = stripRef.current; - if (!strip) { - return; - } - function onWheel(event: WheelEvent): void { - const el = stripRef.current; - if (!el) { - return; - } - if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) { - return; - } - if (el.scrollWidth <= el.clientWidth + 1) { - return; - } - event.preventDefault(); - el.scrollLeft += event.deltaY; - } - strip.addEventListener("wheel", onWheel, { passive: false }); - return () => strip.removeEventListener("wheel", onWheel); - }, [profile]); - - function scrollStrip(by: number): void { - stripRef.current?.scrollBy({ left: by, behavior: "smooth" }); - } return (
{dict.loadError}

) : null} - {partialFail ? ( + {isDeterministicMiss ? ( +
+

{dict.noGameData}

+
+ ) : partialFail ? (

{dict.partialFail}

@@ -346,7 +184,7 @@ export function GameProfileModal({ ) : null} {profile?.steamId ? ( - {hasDetails ? ( + {profile && hasDetails ? (
{profile.developers.length > 0 ? (
@@ -412,7 +250,7 @@ export function GameProfileModal({ {dict.releaseDate}
- {formatReleaseDate(profile.releaseDate, tag)} + {formatReleaseDate(profile.releaseDate, localeTag)}
) : null} @@ -447,193 +285,25 @@ export function GameProfileModal({
) : null} - {profile && hasReviews ? ( -
- ) : null} + {profile ? : null} - {profile?.itad && (best || low) ? ( -
-

- {dict.prices} -

- {isFree ? ( -

- {dict.free} -

- ) : ( -
- {best ? ( - -

- {dict.bestPrice} · {best.shop} -

-

- {formatPrice(best, tag)}{" "} - {discountPercent(best) ? ( - - -%{discountPercent(best)} - - ) : null} -

-
- ) : null} - {low ? ( -
-

- {dict.historyLow} · {low.shop} -

-

- {formatPrice(low, tag)} -

-
- ) : null} -
- )} -
+ {profile?.itad && (bestPrice || historyLow) ? ( + ) : null} {profile && profile.screenshots.length > 0 ? ( -
-
-

- {dict.screenshots} -

-
- - -
-
-
- {profile.screenshots.map((shot) => ( - - ))} -
-
+ ) : null}
diff --git a/components/game-profile-sections.test.tsx b/components/game-profile-sections.test.tsx new file mode 100644 index 0000000..5478c69 --- /dev/null +++ b/components/game-profile-sections.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ModalPrices, ModalReviews } from "@/components/game-profile-sections"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; +import type { GamePricePoint } from "@/lib/game/game-profile-types"; +import { profileFixture } from "@/test/fixtures"; + +describe("ModalReviews", () => { + it("renders nothing without review sources", () => { + const dict = getDictionary("en"); + const { container } = render( + , + ); + expect(container.textContent).toBe(""); + }); + + it("renders Steam review data", () => { + const dict = getDictionary("en"); + render( + , + ); + expect(screen.getByText("Positive")).toBeDefined(); + }); +}); + +describe("ModalPrices", () => { + it("renders the free badge", () => { + const dict = getDictionary("en"); + const bestPrice: GamePricePoint = { + price: 0, + regular: 0, + currency: "USD", + shop: "Steam", + url: "", + }; + render( + , + ); + expect(screen.getByText(dict.discord.gameModal.free)).toBeDefined(); + }); +}); diff --git a/components/game-profile-sections.tsx b/components/game-profile-sections.tsx new file mode 100644 index 0000000..175a037 --- /dev/null +++ b/components/game-profile-sections.tsx @@ -0,0 +1,262 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useRef } from "react"; +import type { GamePricePoint, GameProfile } from "@/lib/game/game-profile-types"; +import { + describeProfile, + discountPercent, + formatCount, + formatPrice, + formatRatingPercent, + localizeScore, + type ModalDict, +} from "@/lib/game/game-format"; +import { steamdbAppUrl } from "@/lib/game/discord-app"; + +export function ModalReviews({ + profile, + dict, + localeTag, +}: { + profile: GameProfile; + dict: ModalDict; + localeTag: string; +}) { + const { dbRating, hasReviews, metascore, opencritic } = describeProfile(profile); + if (!hasReviews) { + return null; + } + const steamdbRating: { score: number; url: string } | null = + dbRating != null && profile.steamId + ? { score: dbRating, url: steamdbAppUrl(profile.steamId) } + : null; + return ( + + ); +} + +export function ModalPrices({ + dict, + localeTag, + bestPrice, + historyLow, + isFree, +}: { + dict: ModalDict; + localeTag: string; + bestPrice: GamePricePoint | null; + historyLow: GamePricePoint | null; + isFree: boolean; +}) { + return ( +
+

+ {dict.prices} +

+ {isFree ? ( +

+ {dict.free} +

+ ) : ( +
+ {bestPrice ? ( + +

+ {dict.bestPrice} · {bestPrice.shop} +

+

+ {formatPrice(bestPrice, localeTag)}{" "} + {discountPercent(bestPrice) ? ( + + -%{discountPercent(bestPrice)} + + ) : null} +

+
+ ) : null} + {historyLow ? ( +
+

+ {dict.historyLow} · {historyLow.shop} +

+

+ {formatPrice(historyLow, localeTag)} +

+
+ ) : null} +
+ )} +
+ ); +} + +export function ModalScreenshots({ + profile, + dict, + hero, + onPick, +}: { + profile: GameProfile; + dict: ModalDict; + hero: string | null; + onPick: (shot: string) => void; +}) { + const stripRef = useRef(null); + + useEffect(() => { + const strip = stripRef.current; + if (!strip) { + return; + } + function onWheel(event: WheelEvent): void { + const el = stripRef.current; + if (!el) { + return; + } + if (Math.abs(event.deltaX) >= Math.abs(event.deltaY)) { + return; + } + if (el.scrollWidth <= el.clientWidth + 1) { + return; + } + event.preventDefault(); + el.scrollLeft += event.deltaY; + } + strip.addEventListener("wheel", onWheel, { passive: false }); + return () => strip.removeEventListener("wheel", onWheel); + }, [profile]); + + function scrollStrip(by: number): void { + stripRef.current?.scrollBy({ left: by, behavior: "smooth" }); + } + + return ( +
+
+

+ {dict.screenshots} +

+
+ + +
+
+
+ {profile.screenshots.map((shot) => ( + + ))} +
+
+ ); +} diff --git a/components/language-switcher.test.tsx b/components/language-switcher.test.tsx new file mode 100644 index 0000000..24fe985 --- /dev/null +++ b/components/language-switcher.test.tsx @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { LanguageSwitcher } from "@/components/language-switcher"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("LanguageSwitcher", () => { + it("marks the current locale pressed and switches on click", () => { + Object.defineProperty(window, "location", { + configurable: true, + value: { reload: vi.fn() }, + }); + let cookie = ""; + Object.defineProperty(document, "cookie", { + configurable: true, + get: () => cookie, + set: (v: string) => { + cookie = v; + }, + }); + render(); + expect(screen.getByText("TR").getAttribute("aria-pressed")).toBe("true"); + fireEvent.click(screen.getByText("EN")); + expect(cookie).toContain("locale=en"); + expect(window.location.reload).toHaveBeenCalledOnce(); + }); +}); diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx index f888b90..988b6e3 100644 --- a/components/language-switcher.tsx +++ b/components/language-switcher.tsx @@ -1,6 +1,6 @@ "use client"; -import { LOCALES, LOCALE_LABELS, LOCALE_COOKIE, isLocale, type Locale } from "@/lib/i18n/locales"; +import { LOCALES, LOCALE_LABELS, LOCALE_COOKIE, type Locale } from "@/lib/i18n/locales"; function writeLocaleCookie(locale: Locale): void { document.cookie = `${LOCALE_COOKIE}=${locale}; path=/; max-age=31536000; SameSite=Lax`; @@ -8,7 +8,7 @@ function writeLocaleCookie(locale: Locale): void { export function LanguageSwitcher({ current }: { current: Locale }) { function switchTo(locale: Locale) { - if (locale === current || !isLocale(locale)) { + if (locale === current) { return; } writeLocaleCookie(locale); diff --git a/components/projects-section.test.tsx b/components/projects-section.test.tsx new file mode 100644 index 0000000..70899d2 --- /dev/null +++ b/components/projects-section.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { ProjectsSection } from "@/components/projects-section"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; +import { repoFixture } from "@/test/fixtures"; + +describe("ProjectsSection", () => { + it("renders repositories", () => { + const dict = getDictionary("en"); + render(); + expect(screen.getByText("demo")).toBeDefined(); + expect(screen.getByText("A demo repo")).toBeDefined(); + cleanup(); + }); + + it("renders the quiet state without repositories", () => { + const dict = getDictionary("en"); + render(); + expect(screen.getAllByText(dict.projects.title).length).toBeGreaterThan(0); + cleanup(); + }); +}); diff --git a/components/projects-section.tsx b/components/projects-section.tsx index f4d199f..b8ffee6 100644 --- a/components/projects-section.tsx +++ b/components/projects-section.tsx @@ -1,9 +1,9 @@ import type { Dictionary } from "@/lib/i18n/get-dictionary"; import type { Locale } from "@/lib/i18n/locales"; import type { GiteaRepo } from "@/lib/gitea"; -import { SectionHeading } from "./section-heading"; +import { SectionHeading } from "@/components/section-heading"; -const LANGUAGE_COLORS: Record = { +const LANGUAGE_COLORS: Partial> = { "C#": "#178600", TypeScript: "#3178c6", JavaScript: "#f1e05a", diff --git a/components/section-heading.test.tsx b/components/section-heading.test.tsx new file mode 100644 index 0000000..c14aa40 --- /dev/null +++ b/components/section-heading.test.tsx @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SectionHeading } from "@/components/section-heading"; + +describe("SectionHeading", () => { + it("renders kicker, title, and subtitle", () => { + render(); + expect(screen.getByText("// src")).toBeDefined(); + expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Title"); + expect(screen.getByText("Sub")).toBeDefined(); + }); + + it("omits subtitle when absent", () => { + const { container } = render(); + expect(container.querySelector("p.mt-2")).toBeNull(); + }); +}); diff --git a/components/site-footer.test.tsx b/components/site-footer.test.tsx new file mode 100644 index 0000000..6dc241d --- /dev/null +++ b/components/site-footer.test.tsx @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SiteFooter } from "@/components/site-footer"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; + +describe("SiteFooter", () => { + it("renders brand, rights, and source links", () => { + const dict = getDictionary("en"); + const { container } = render(); + expect(container.querySelector('a[href="https://fristy.net"]')).not.toBeNull(); + expect(container.textContent).toContain(dict.footer.rights); + const links = screen.getAllByRole("link"); + expect(links.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/components/site-header.test.tsx b/components/site-header.test.tsx new file mode 100644 index 0000000..fba58a6 --- /dev/null +++ b/components/site-header.test.tsx @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { SiteHeader } from "@/components/site-header"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; + +describe("SiteHeader", () => { + it("renders brand, nav anchors, and language options", () => { + const dict = getDictionary("tr"); + const { container } = render(); + expect(container.querySelector('a[href="#top"]')).not.toBeNull(); + expect(screen.getByText(dict.nav.discord)).toBeDefined(); + expect(screen.getByRole("group", { name: "language" })).toBeDefined(); + }); +}); diff --git a/components/site-header.tsx b/components/site-header.tsx index cd44e47..c906a79 100644 --- a/components/site-header.tsx +++ b/components/site-header.tsx @@ -1,6 +1,6 @@ import type { Dictionary } from "@/lib/i18n/get-dictionary"; import type { Locale } from "@/lib/i18n/locales"; -import { LanguageSwitcher } from "./language-switcher"; +import { LanguageSwitcher } from "@/components/language-switcher"; export function SiteHeader({ dict, locale }: { dict: Dictionary["nav"]; locale: Locale }) { return ( diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..42ca4f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +services: + web: + image: git.fristy.net/fristy/fristynet:latest + restart: unless-stopped + environment: + TWITCH_CLIENT_ID: ${TWITCH_CLIENT_ID} + TWITCH_CLIENT_SECRET: ${TWITCH_CLIENT_SECRET} + ITAD_API_KEY: ${ITAD_API_KEY} diff --git a/hooks/use-game-profile.test.tsx b/hooks/use-game-profile.test.tsx new file mode 100644 index 0000000..d21a255 --- /dev/null +++ b/hooks/use-game-profile.test.tsx @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useGameProfile } from "@/hooks/use-game-profile"; +import { profileFixture } from "@/test/fixtures"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("useGameProfile", () => { + it("loads a profile and derives view state", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => profileFixture() }) as Response), + ); + const { result } = renderHook(() => useGameProfile("1", "en", 0)); + await waitFor(() => expect(result.current.profile?.name).toBe("Test Game")); + expect(result.current.failed).toBe(false); + expect(result.current.localeTag).toBe("en-US"); + expect(result.current.hasDetails).toBe(true); + }); + + it("flags failure when the API errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500 }) as Response), + ); + const { result } = renderHook(() => useGameProfile("1", "en", 0)); + await waitFor(() => expect(result.current.failed).toBe(true)); + expect(result.current.profile).toBeNull(); + }); + + it("retries on demand", async () => { + let calls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + calls += 1; + return { ok: false, status: 500 } as Response; + }), + ); + const { result } = renderHook(() => useGameProfile("1", "en", 0)); + await waitFor(() => expect(result.current.failed).toBe(true)); + expect(result.current.canRetry).toBe(true); + result.current.retry(); + await waitFor(() => expect(calls).toBeGreaterThan(1)); + }); + + it("blocks double retry during cooldown and restores on reset", async () => { + let calls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async () => { + calls += 1; + return { ok: false, status: 500 } as Response; + }), + ); + const { result } = renderHook(() => useGameProfile("1", "en", 0)); + await waitFor(() => expect(result.current.failed).toBe(true)); + act(() => { + result.current.retry(); + }); + expect(result.current.canRetry).toBe(false); + const afterFirstRetry = calls; + act(() => { + result.current.retry(); + }); + expect(calls).toBe(afterFirstRetry); + act(() => { + result.current.reset(); + }); + expect(result.current.canRetry).toBe(true); + }); +}); diff --git a/hooks/use-game-profile.ts b/hooks/use-game-profile.ts new file mode 100644 index 0000000..29d6242 --- /dev/null +++ b/hooks/use-game-profile.ts @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { GameProfile, ItadReview } from "@/lib/game/game-profile-types"; +import type { Locale } from "@/lib/i18n/locales"; +import { describeProfile, resolveGameLocale } from "@/lib/game/game-format"; +import { fetchJson } from "@/lib/client-fetch"; + +export const MAX_RETRIES = 3; +export const RETRY_COOLDOWN_MS = 5_000; + +export interface GameProfileState { + profile: GameProfile | null; + failed: boolean; + localeTag: string; + hero: string | null; + dbRating: number | null; + itadReviews: ItadReview[]; + hasDetails: boolean; + hasReviews: boolean; + partialFail: boolean; + metascore: ItadReview | null; + opencritic: ItadReview | null; + isDeterministicMiss: boolean; + canRetry: boolean; + setBannerPick: (shot: string | null) => void; + retry: () => void; + reset: () => void; +} + +export function useGameProfile( + applicationId: string, + lang: Locale, + heroSeed: number, +): GameProfileState { + const [profile, setProfile] = useState(null); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + const [bannerPick, setBannerPick] = useState(null); + const [retries, setRetries] = useState(0); + const [cooling, setCooling] = useState(false); + const coolingTimer = useRef(undefined); + const { localeTag, country } = resolveGameLocale(lang); + + useEffect(() => { + let cancelled = false; + async function load(): Promise { + const json = await fetchJson( + `/api/game/${applicationId}?lang=${lang}&country=${country}`, + ); + if (!cancelled) { + if (json) { + setProfile(json); + setFailed(false); + } else { + setFailed(true); + } + } + } + void load(); + return () => { + cancelled = true; + }; + }, [applicationId, lang, country, attempt]); + + const partialFail = + !!profile && !profile.sources.igdb && !profile.sources.steam && !profile.sources.itad; + const isDeterministicMiss = + !!profile && !profile.steamId && !profile.sources.igdb && !profile.sources.itad; + + const hero = useMemo(() => { + if (bannerPick) { + return bannerPick; + } + const shots = profile?.screenshots ?? []; + if (shots.length > 0) { + return shots[heroSeed % shots.length] ?? null; + } + return profile?.artworkUrl ?? null; + }, [profile, heroSeed, bannerPick]); + + const summary = describeProfile(profile); + + const canRetry = retries < MAX_RETRIES && !cooling; + + function retry(): void { + if (!canRetry) { + return; + } + setCooling(true); + setRetries((n) => n + 1); + setFailed(false); + setAttempt((n) => n + 1); + window.clearTimeout(coolingTimer.current); + coolingTimer.current = window.setTimeout(() => setCooling(false), RETRY_COOLDOWN_MS); + } + + function reset(): void { + window.clearTimeout(coolingTimer.current); + setCooling(false); + setRetries(0); + setProfile(null); + setFailed(false); + setAttempt((n) => n + 1); + } + + useEffect(() => () => window.clearTimeout(coolingTimer.current), []); + + return { + profile, + failed, + localeTag, + hero, + ...summary, + partialFail, + isDeterministicMiss, + canRetry, + setBannerPick, + retry, + reset, + }; +} diff --git a/hooks/use-lanyard.test.tsx b/hooks/use-lanyard.test.tsx new file mode 100644 index 0000000..9efd29f --- /dev/null +++ b/hooks/use-lanyard.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useLanyard } from "@/hooks/use-lanyard"; +import { lanyardFixture } from "@/test/fixtures"; + +describe("useLanyard", () => { + it("returns the initial snapshot synchronously", () => { + const initial = lanyardFixture(); + const { result, unmount } = renderHook(() => useLanyard(initial)); + expect(result.current?.discord_user.username).toBe("testuser"); + unmount(); + }); + + it("starts empty without initial data", () => { + const { result, unmount } = renderHook(() => useLanyard(null)); + expect(result.current).toBeNull(); + unmount(); + }); +}); diff --git a/hooks/use-lanyard.ts b/hooks/use-lanyard.ts new file mode 100644 index 0000000..199d10b --- /dev/null +++ b/hooks/use-lanyard.ts @@ -0,0 +1,207 @@ +import { useEffect, useState } from "react"; +import type { LanyardData } from "@/lib/game/lanyard"; +import { DISCORD_ID, LANYARD_API, LANYARD_WS } from "@/lib/game/lanyard"; +import { fetchJson } from "@/lib/client-fetch"; + +const WS_OPEN_TIMEOUT_MS = 10_000; +const RECONNECT_BASE_MS = 2_000; +const RECONNECT_MAX_MS = 30_000; +const SAFETY_POLL_MS = 60_000; +const HEARTBEAT_FALLBACK_MS = 30_000; + +interface LanyardMessage { + op: number; + t?: string; + d?: unknown; +} + +function parseLanyardMessage(data: unknown): LanyardMessage | null { + if (typeof data !== "string") { + return null; + } + try { + return JSON.parse(data) as LanyardMessage; + } catch { + return null; + } +} + +function isLanyardData(value: unknown): value is LanyardData { + if (typeof value !== "object" || value === null) { + return false; + } + const candidate = value as { activities?: unknown; discord_status?: unknown }; + if (!Array.isArray(candidate.activities)) { + return false; + } + const status = candidate.discord_status; + return status === "online" || status === "idle" || status === "dnd" || status === "offline"; +} +function nextBackoffDelay(current: number): number { + return Math.min(current * 2, RECONNECT_MAX_MS); +} + +async function fetchLanyardSnapshot(): Promise { + const json = await fetchJson<{ success: boolean; data: LanyardData }>(LANYARD_API, { + cache: "no-store", + }); + return json && json.success ? json.data : null; +} + +interface LanyardSocketEvents { + onData: (data: LanyardData) => void; +} + +interface LanyardSocket { + start: () => void; + stop: () => void; +} + +function createLanyardSocket(events: LanyardSocketEvents): LanyardSocket { + let disposed = false; + let socket: WebSocket | null = null; + let heartbeatTimer: number | undefined; + let reconnectTimer: number | undefined; + let safetyTimer: number | undefined; + let reconnectDelay = RECONNECT_BASE_MS; + + async function pollOnce(): Promise { + const snapshot = await fetchLanyardSnapshot(); + if (snapshot && !disposed) { + events.onData(snapshot); + } + } + + function scheduleReconnect(): void { + if (disposed) { + return; + } + window.clearTimeout(reconnectTimer); + reconnectTimer = window.setTimeout(() => { + reconnectDelay = nextBackoffDelay(reconnectDelay); + connect(); + }, reconnectDelay); + } + + function dropSocket(current: WebSocket): void { + current.onclose = null; + current.onerror = null; + current.onmessage = null; + if (socket === current) { + socket = null; + } + try { + current.close(); + } catch { + if (socket === current) { + socket = null; + } + } + } + + function connect(): void { + if (disposed) { + return; + } + let current: WebSocket; + try { + current = new WebSocket(LANYARD_WS); + } catch { + scheduleReconnect(); + return; + } + socket = current; + let settled = false; + + const openTimeout = window.setTimeout(() => { + if (!settled) { + settled = true; + dropSocket(current); + if (!disposed) { + void pollOnce(); + scheduleReconnect(); + } + } + }, WS_OPEN_TIMEOUT_MS); + + const handleDrop = (): void => { + if (settled && socket !== current) { + return; + } + settled = true; + window.clearTimeout(openTimeout); + window.clearInterval(heartbeatTimer); + dropSocket(current); + if (!disposed) { + void pollOnce(); + scheduleReconnect(); + } + }; + + current.onmessage = (event: MessageEvent) => { + const message = parseLanyardMessage(event.data); + if (!message) { + return; + } + if (message.op === 1) { + settled = true; + window.clearTimeout(openTimeout); + reconnectDelay = RECONNECT_BASE_MS; + const interval = + (message.d as { heartbeat_interval?: number } | undefined) + ?.heartbeat_interval ?? HEARTBEAT_FALLBACK_MS; + window.clearInterval(heartbeatTimer); + heartbeatTimer = window.setInterval(() => { + try { + current.send(JSON.stringify({ op: 3 })); + } catch { + handleDrop(); + } + }, interval); + current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } })); + } else if ( + message.op === 0 && + (message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE") + ) { + if (!disposed && isLanyardData(message.d)) { + events.onData(message.d); + } + } + }; + current.onerror = handleDrop; + current.onclose = handleDrop; + } + + function start(): void { + connect(); + safetyTimer = window.setInterval(() => { + if (!socket || socket.readyState !== WebSocket.OPEN) { + void pollOnce(); + } + }, SAFETY_POLL_MS); + } + + function stop(): void { + disposed = true; + window.clearTimeout(reconnectTimer); + window.clearInterval(safetyTimer); + window.clearInterval(heartbeatTimer); + if (socket) { + dropSocket(socket); + } + } + + return { start, stop }; +} + +export function useLanyard(initial: LanyardData | null): LanyardData | null { + const [data, setData] = useState(initial); + + useEffect(() => { + const controller = createLanyardSocket({ onData: (snapshot) => setData(snapshot) }); + controller.start(); + return () => controller.stop(); + }, []); + + return data; +} diff --git a/lib/api-cors.ts b/lib/api-cors.ts deleted file mode 100644 index f088571..0000000 --- a/lib/api-cors.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextResponse } from "next/server"; - -const ALLOWED_ORIGIN = "https://fristy.net"; - -export function isAllowedOrigin(request: Request): boolean { - const origin = request.headers.get("origin"); - return origin === null || origin === ALLOWED_ORIGIN; -} - -export function corsHeaders(): HeadersInit { - return { - "Access-Control-Allow-Origin": ALLOWED_ORIGIN, - Vary: "Origin", - }; -} - -export function forbidden(): NextResponse { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); -} diff --git a/lib/api-route.test.ts b/lib/api-route.test.ts new file mode 100644 index 0000000..e9955d2 --- /dev/null +++ b/lib/api-route.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { invalidIdResponse, jsonErrorWithCors, jsonWithCors, parseAppId } from "@/lib/api-route"; + +function contextWith(id: string) { + return { params: Promise.resolve({ id }) }; +} + +describe("parseAppId", () => { + it("accepts numeric ids", async () => { + await expect(parseAppId(contextWith("12345"))).resolves.toBe("12345"); + }); + + it("rejects non-numeric ids", async () => { + await expect(parseAppId(contextWith("abc"))).resolves.toBeNull(); + await expect(parseAppId(contextWith(""))).resolves.toBeNull(); + }); +}); + +describe("invalidIdResponse", () => { + it("returns a 400 JSON response", async () => { + const response = invalidIdResponse(); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid application id" }); + }); +}); + +describe("jsonErrorWithCors", () => { + it("sets status, cache, and CORS headers", async () => { + const response = jsonErrorWithCors({ error: "Nope" }, 404); + expect(response.status).toBe(404); + expect(response.headers.get("Cache-Control")).toContain("s-maxage=60"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + await expect(response.json()).resolves.toEqual({ error: "Nope" }); + }); +}); +describe("jsonWithCors", () => { + it("sets cache and CORS headers", async () => { + const response = jsonWithCors({ ok: true }, 3600); + expect(response.headers.get("Cache-Control")).toContain("s-maxage=3600"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + await expect(response.json()).resolves.toEqual({ ok: true }); + }); + + it("echoes a validated dev origin instead of the production URL", async () => { + const response = jsonWithCors({ ok: true }, 3600, "http://localhost:3000"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:3000"); + }); + + it("falls back to production for untrusted origins", async () => { + const response = jsonWithCors({ ok: true }, 3600, "https://evil.example"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + }); +}); diff --git a/lib/api-route.ts b/lib/api-route.ts new file mode 100644 index 0000000..b19b561 --- /dev/null +++ b/lib/api-route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import { DEV_ORIGINS, SITE_URL } from "@/lib/site"; + +const ALLOWED_ORIGIN = SITE_URL; + +function isDev(): boolean { + return process.env.NODE_ENV !== "production"; +} + +export function isAllowedOrigin(request: Request): boolean { + const origin = request.headers.get("origin"); + if (origin === null) { + return true; + } + if (origin === ALLOWED_ORIGIN) { + return true; + } + if (isDev() && DEV_ORIGINS.has(origin)) { + return true; + } + return false; +} + +function resolveAllowedOrigin(origin: string | null): string { + if ( + origin !== null && + (origin === ALLOWED_ORIGIN || (isDev() && DEV_ORIGINS.has(origin))) + ) { + return origin; + } + return ALLOWED_ORIGIN; +} + +export function corsHeaders(origin?: string | null): Record { + return { + "Access-Control-Allow-Origin": resolveAllowedOrigin(origin ?? null), + Vary: "Origin", + }; +} + +export function forbidden(origin?: string | null): NextResponse { + return NextResponse.json( + { error: "Forbidden" }, + { + status: 403, + headers: { + "Cache-Control": "public, s-maxage=0, stale-while-revalidate=86400", + ...corsHeaders(origin), + }, + }, + ); +} + +export async function parseAppId(context: { + params: Promise<{ id: string }>; +}): Promise { + const { id } = await context.params; + if (!/^\d{1,20}$/.test(id)) { + return null; + } + return id; +} + +export function invalidIdResponse(origin?: string | null): NextResponse { + return jsonErrorWithCors({ error: "Invalid application id" }, 400, 0, origin); +} + +export function jsonErrorWithCors( + data: unknown, + status: number, + sMaxage = 60, + origin?: string | null, +): NextResponse { + return NextResponse.json(data, { + status, + headers: { + "Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`, + ...corsHeaders(origin), + }, + }); +} +export function jsonWithCors( + data: unknown, + sMaxage = 3600, + origin?: string | null, +): NextResponse { + return NextResponse.json(data, { + headers: { + "Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`, + ...corsHeaders(origin), + }, + }); +} + +export function requestOrigin(request: Request): string | null { + return request.headers.get("origin"); +} diff --git a/lib/client-fetch.test.ts b/lib/client-fetch.test.ts new file mode 100644 index 0000000..32f8bb2 --- /dev/null +++ b/lib/client-fetch.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchJson } from "@/lib/client-fetch"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchJson", () => { + it("returns parsed JSON on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ a: 1 }) }) as Response), + ); + await expect(fetchJson<{ a: number }>("https://example.com")).resolves.toEqual({ a: 1 }); + }); + + it("returns null on HTTP error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500 }) as Response), + ); + await expect(fetchJson("https://example.com")).resolves.toBeNull(); + }); + + it("returns null on network failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + await expect(fetchJson("https://example.com")).resolves.toBeNull(); + }); +}); diff --git a/lib/client-fetch.ts b/lib/client-fetch.ts new file mode 100644 index 0000000..d51a8f9 --- /dev/null +++ b/lib/client-fetch.ts @@ -0,0 +1,15 @@ +import { warnDev } from "@/lib/log"; + +export async function fetchJson(url: string, init?: RequestInit): Promise { + try { + const response = await fetch(url, init); + if (!response.ok) { + warnDev(`client fetch failed: ${url.split("?")[0]} -> ${response.status}`); + return null; + } + return (await response.json()) as T; + } catch { + warnDev(`client fetch failed: ${url.split("?")[0]}`); + return null; + } +} diff --git a/lib/game-profile.ts b/lib/game-profile.ts deleted file mode 100644 index 53b9ab3..0000000 --- a/lib/game-profile.ts +++ /dev/null @@ -1,635 +0,0 @@ -import { getDiscordAppInfo } from "@/lib/lanyard"; - -export interface GameExternalLink { - label: string; - url: string; -} - -export interface GameReviewSummary { - label: string; - total: number | null; - positive: number | null; - negative: number | null; - url?: string; -} - -export function steamdbRating(positive: number, total: number): number | null { - if ( - !Number.isFinite(positive) || - !Number.isFinite(total) || - total <= 0 || - positive < 0 || - positive > total - ) { - return null; - } - const score = positive / total; - return score - (score - 0.5) * 2 ** -Math.log10(total + 1); -} - -export interface GamePricePoint { - price: number; - regular: number; - currency: string; - shop: string; - url: string; -} - -export interface ItadReview { - score: number | null; - source: string; - count: number | null; - url: string; -} - -export interface GameProfile { - applicationId: string; - gameId: string; - gameUrl: string; - name: string; - genres: string[]; - description: string | null; - developers: string[]; - publishers: string[]; - releaseDate: string | null; - platforms: string[]; - links: GameExternalLink[]; - coverUrl: string | null; - artworkUrl: string | null; - screenshots: string[]; - steamId: string | null; - steamUrl: string | null; - igdbUrl: string | null; - steamReviews: GameReviewSummary | null; - itad: { - prices: GamePricePoint | null; - historyLow: GamePricePoint | null; - pageUrl: string | null; - playerCount: number | null; - reviews: ItadReview[]; - } | null; - sources: { - igdb: boolean; - steam: boolean; - itad: boolean; - }; -} - -interface DetectableEntry { - id: string; - name: string; - third_party_skus?: Array<{ distributor: string; id: string }>; -} - -let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null; - -async function getDetectables(): Promise { - if (detectablesCache && Date.now() < detectablesCache.expires) { - return detectablesCache.data; - } - try { - const response = await fetch("https://cdn.discordapp.com/detectables/games-v1.json"); - if (!response.ok) { - console.error(`detectables failed: ${response.status}`); - return detectablesCache?.data ?? []; - } - const data = (await response.json()) as DetectableEntry[]; - detectablesCache = { data, expires: Date.now() + 86_400_000 }; - return data; - } catch { - return detectablesCache?.data ?? []; - } -} - -const DISTRIBUTOR_PLATFORM: Record = { - xbox: "Xbox", - xbox_title: "Xbox", - xbox_game_pass: "Xbox", - playstation: "PlayStation", - nintendo: "Switch", -}; - -function steamIdFromEntry(entry: DetectableEntry | undefined): string | null { - const sku = entry?.third_party_skus?.find( - (s) => s.distributor === "steam" && /^\d+$/.test(s.id), - ); - return sku?.id ?? null; -} - -interface SteamAppDetails { - success: boolean; - data?: { - name?: string; - short_description?: string; - header_image?: string; - genres?: Array<{ description?: string }>; - developers?: string[]; - publishers?: string[]; - platforms?: { windows?: boolean; mac?: boolean; linux?: boolean }; - release_date?: { date?: string }; - screenshots?: Array<{ path_full?: string }>; - }; -} - -const STEAM_LANG: Record = { - tr: "turkish", - en: "english", - de: "german", -}; - -async function getSteamDetails( - steamId: string, - lang = "en", -): Promise { - try { - const steamLang = STEAM_LANG[lang] ?? "english"; - const response = await fetch( - `https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=US`, - { next: { revalidate: 86_400 } }, - ); - if (!response.ok) { - console.error(`steam details failed: ${steamId} ${response.status}`); - return null; - } - const json = (await response.json()) as Record; - const entry = json[steamId]; - return entry?.success ? (entry.data ?? null) : null; - } catch { - return null; - } -} - -interface SteamReviewQuery { - review_score_desc?: string; - total_positive?: number; - total_negative?: number; - total_reviews?: number; -} - -async function getSteamReviewSummary(steamId: string): Promise { - try { - const url = - `https://store.steampowered.com/appreviews/${steamId}` + - "?json=1&language=all&purchase_type=all&num_per_page=0"; - const response = await fetch(url, { next: { revalidate: 3600 } }); - if (!response.ok) { - console.error(`steam reviews failed: ${steamId} ${response.status}`); - return null; - } - const json = (await response.json()) as { query_summary?: SteamReviewQuery }; - const summary = json.query_summary; - if (!summary || typeof summary.total_reviews !== "number") { - return null; - } - return { - label: summary.review_score_desc ?? "", - total: summary.total_reviews, - positive: summary.total_positive ?? null, - negative: summary.total_negative ?? null, - url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`, - }; - } catch { - return null; - } -} - -interface IgdbGame { - name?: string; - slug?: string; - genres?: Array<{ name?: string }>; - summary?: string; - storyline?: string; - cover?: { url?: string }; - artworks?: Array<{ url?: string }>; - screenshots?: Array<{ url?: string }>; - websites?: Array<{ url?: string; category?: number }>; - involved_companies?: Array<{ - developer?: boolean; - publisher?: boolean; - company?: { name?: string }; - }>; - platforms?: Array<{ abbreviation?: string; name?: string }>; - first_release_date?: number; -} - -let igdbTokenCache: { token: string; expires: number } | null = null; - -let igdbKeyWarned = false; - -async function getIgdbToken(): Promise { - const clientId = process.env.TWITCH_CLIENT_ID; - const clientSecret = process.env.TWITCH_CLIENT_SECRET; - if (!clientId || !clientSecret) { - if (!igdbKeyWarned) { - igdbKeyWarned = true; - console.error("igdb keys missing: set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET"); - } - return null; - } - if (igdbTokenCache && Date.now() < igdbTokenCache.expires) { - return igdbTokenCache.token; - } - try { - const response = await fetch("https://id.twitch.tv/oauth2/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - grant_type: "client_credentials", - }), - }); - if (!response.ok) { - console.error(`igdb token failed: ${response.status}`); - return null; - } - const json = (await response.json()) as { access_token?: string; expires_in?: number }; - if (!json.access_token) { - return null; - } - igdbTokenCache = { - token: json.access_token, - expires: Date.now() + (json.expires_in ?? 3600) * 1000 - 60_000, - }; - return json.access_token; - } catch { - return null; - } -} - -async function igdbQuery(token: string, endpoint: string, body: string): Promise { - try { - const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, { - method: "POST", - headers: { - "Client-ID": process.env.TWITCH_CLIENT_ID ?? "", - Authorization: `Bearer ${token}`, - "Content-Type": "text/plain", - }, - body, - next: { revalidate: 86_400 }, - }); - if (!response.ok) { - console.error(`igdb query failed: ${endpoint} ${response.status}`); - return null; - } - return (await response.json()) as T; - } catch { - return null; - } -} - -const IGDB_GAME_FIELDS = - "name,slug,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," + - "websites.url,websites.category,involved_companies.developer," + - "involved_companies.publisher,involved_companies.company.name," + - "platforms.abbreviation,platforms.name,first_release_date;"; - -function igdbImage(url: string | undefined, size: string): string | null { - if (!url) { - return null; - } - const full = url.startsWith("//") ? `https:${url}` : url; - return full.replace(/t_thumb|t_cover_small|t_logo_med/, size); -} - -async function getIgdbGame(name: string, steamId: string | null): Promise { - const token = await getIgdbToken(); - if (!token) { - return null; - } - if (steamId) { - const external = await igdbQuery>( - token, - "external_games", - `fields game; where uid = "${steamId}"; limit 1;`, - ); - const gameId = external?.[0]?.game; - if (gameId) { - const games = await igdbQuery( - token, - "games", - `fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`, - ); - if (games?.[0]) { - return games[0]; - } - } - } - const cleaned = name - .replace(/[®™©]/g, "") - .replace(/\s{2,}/g, " ") - .trim(); - const norm = (v: string): string => - v - .toLowerCase() - .replace(/[®™©]/g, "") - .replace(/[^a-z0-9]+/g, " ") - .trim(); - const escaped = cleaned.replace(/"/g, '\\"'); - const games = await igdbQuery( - token, - "games", - `fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`, - ); - if (!games || games.length === 0) { - return null; - } - const wanted = norm(name); - return games.find((g) => g.name && norm(g.name) === wanted) ?? games[0] ?? null; -} - -const IGDB_WEBSITE_LABELS: Record = { - 1: "Website", - 4: "Facebook", - 5: "X", - 6: "Twitch", - 8: "Instagram", - 9: "YouTube", - 13: "Steam", - 14: "Reddit", - 16: "Epic", - 17: "GOG", - 18: "Discord", -}; - -interface ItadGameInfo { - title?: string; - assets?: { boxart?: string; banner600?: string }; - tags?: string[]; - releaseDate?: string | null; - developers?: Array<{ name?: string }>; - publishers?: Array<{ name?: string }>; - reviews?: Array<{ - score?: number | null; - source?: string; - count?: number | null; - url?: string; - }>; - players?: { recent?: number } | null; - urls?: { game?: string }; -} - -interface ItadOverviewPrice { - current?: { - shop?: { name?: string }; - price?: { amount?: number; currency?: string }; - regular?: { amount?: number }; - cut?: number; - url?: string; - } | null; - lowest?: { - shop?: { name?: string }; - price?: { amount?: number; currency?: string }; - regular?: { amount?: number }; - cut?: number; - } | null; -} - -let itadKeyWarned = false; - -async function itadFetch(path: string, init?: RequestInit): Promise { - const key = process.env.ITAD_API_KEY; - if (!key) { - if (!itadKeyWarned) { - itadKeyWarned = true; - console.error("itad key missing: set ITAD_API_KEY"); - } - return null; - } - try { - const separator = path.includes("?") ? "&" : "?"; - const response = await fetch( - `https://api.isthereanydeal.com${path}${separator}key=${key}`, - { - ...init, - next: { revalidate: 3600 }, - }, - ); - if (!response.ok) { - console.error(`itad failed: ${path.split("?")[0]} ${response.status}`); - return null; - } - return (await response.json()) as T; - } catch { - return null; - } -} - -async function getItadId(title: string): Promise { - const results = await itadFetch>( - `/games/search/v1?title=${encodeURIComponent(title)}&results=5`, - ); - if (!results || results.length === 0) { - return null; - } - const norm = (value: string): string => - value - .toLowerCase() - .replace(/[®™©]/g, "") - .replace(/[^a-z0-9]+/g, " ") - .trim(); - const wanted = norm(title); - return results.find((r) => r.title && norm(r.title) === wanted)?.id ?? null; -} - -function toIsoDate(unixSeconds: number | undefined): string | null { - if (!unixSeconds) { - return null; - } - return new Date(unixSeconds * 1000).toISOString().slice(0, 10); -} - -export async function getGameProfile( - applicationId: string, - country = "TR", - lang = "en", -): Promise { - if (!/^\d+$/.test(applicationId)) { - return null; - } - const appInfo = await getDiscordAppInfo(applicationId); - if (!appInfo) { - return null; - } - - const detectables = await getDetectables(); - const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId); - const steamId = steamIdFromEntry(entry); - - const [steam, steamReviews, igdb] = await Promise.all([ - steamId ? getSteamDetails(steamId, lang) : Promise.resolve(null), - steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null), - getIgdbGame(appInfo.name || entry?.name || "", steamId), - ]); - - const itadId = process.env.ITAD_API_KEY - ? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name) - : null; - const [itadInfo, itadOverview] = itadId - ? await Promise.all([ - itadFetch(`/games/info/v2?id=${itadId}`), - itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify([itadId]), - }), - ]) - : [null, null]; - - const companies = igdb?.involved_companies ?? []; - const devList: string[] = - companies.length > 0 - ? [ - ...new Set( - companies - .filter((c) => c.developer) - .map((c) => c.company?.name) - .filter((n): n is string => !!n), - ), - ] - : (steam?.developers ?? - (itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || [])); - const pubList: string[] = - companies.length > 0 - ? [ - ...new Set( - companies - .filter((c) => c.publisher) - .map((c) => c.company?.name) - .filter((n): n is string => !!n), - ), - ] - : (steam?.publishers ?? - (itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || [])); - - const igdbPlatforms = new Set(); - for (const p of igdb?.platforms ?? []) { - const raw = (p.name ?? "").trim(); - if (!raw) { - continue; - } - if (raw === "PC (Microsoft Windows)") { - igdbPlatforms.add("Windows"); - } else if (raw === "Mac") { - igdbPlatforms.add("MacOS"); - } else { - igdbPlatforms.add(raw); - } - } - const platforms = new Set(igdbPlatforms); - if (platforms.size === 0 && steam?.platforms) { - if (steam.platforms.windows) { - platforms.add("Windows"); - } - if (steam.platforms.mac) { - platforms.add("MacOS"); - } - if (steam.platforms.linux) { - platforms.add("Linux"); - } - for (const sku of entry?.third_party_skus ?? []) { - const mapped = DISTRIBUTOR_PLATFORM[sku.distributor]; - if (mapped) { - platforms.add(mapped); - } - } - } - - const links: GameExternalLink[] = []; - for (const site of igdb?.websites ?? []) { - if (!site.url) { - continue; - } - const url = site.url.startsWith("http") ? site.url : `https://${site.url}`; - let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null; - if (!label) { - try { - label = new URL(url).hostname.replace(/^www\./, ""); - } catch { - continue; - } - } - if (!links.some((l) => l.url === url)) { - links.push({ label, url }); - } - } - - const screenshots: string[] = [ - ...(igdb?.screenshots ?? []) - .map((s) => igdbImage(s.url, "t_screenshot_big")) - .filter((u): u is string => !!u), - ...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u), - ].slice(0, 8); - - const priceEntry = itadOverview?.prices?.[0]; - const toPoint = (p: ItadOverviewPrice["current"]): GamePricePoint | null => { - if (!p?.price || typeof p.price.amount !== "number") { - return null; - } - return { - price: p.price.amount, - regular: p.regular?.amount ?? p.price.amount, - currency: p.price.currency ?? "", - shop: p.shop?.name ?? "", - url: p.url ?? "", - }; - }; - - return { - applicationId, - gameId: appInfo.gameId, - gameUrl: appInfo.gameUrl, - name: igdb?.name ?? steam?.name ?? appInfo.name, - genres: [ - ...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n), - ...(igdb?.genres?.length ? [] : (itadInfo?.tags ?? []).slice(0, 5)), - ].slice(0, 6), - description: - lang === "en" - ? (igdb?.summary ?? steam?.short_description ?? null) - : (steam?.short_description ?? igdb?.summary ?? null), - developers: devList, - publishers: pubList, - releaseDate: - toIsoDate(igdb?.first_release_date) ?? - itadInfo?.releaseDate ?? - steam?.release_date?.date ?? - null, - platforms: [...platforms], - links, - coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"), - artworkUrl: - igdbImage(igdb?.artworks?.[0]?.url, "t_1080p") ?? - steam?.header_image ?? - itadInfo?.assets?.banner600 ?? - null, - screenshots, - steamId, - steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null, - igdbUrl: igdb?.slug ? `https://www.igdb.com/games/${igdb.slug}` : null, - steamReviews, - itad: - process.env.ITAD_API_KEY && (itadInfo || priceEntry) - ? { - prices: toPoint(priceEntry?.current), - historyLow: toPoint(priceEntry?.lowest), - pageUrl: itadInfo?.urls?.game ?? null, - playerCount: itadInfo?.players?.recent ?? null, - reviews: (itadInfo?.reviews ?? []) - .filter((r) => r.source && r.url) - .map((r) => ({ - score: r.score ?? null, - source: r.source as string, - count: r.count ?? null, - url: r.url as string, - })), - } - : null, - sources: { - igdb: !!igdb, - steam: !!steam, - itad: !!itadInfo, - }, - }; -} diff --git a/lib/game/detectables.test.ts b/lib/game/detectables.test.ts new file mode 100644 index 0000000..607f74a --- /dev/null +++ b/lib/game/detectables.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { _resetDetectablesCache, getDetectables, steamIdFromEntry } from "@/lib/game/detectables"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + _resetDetectablesCache(); +}); + +describe("steamIdFromEntry", () => { + it("extracts numeric Steam SKUs", () => { + expect( + steamIdFromEntry({ + id: "game", + name: "Game", + third_party_skus: [{ distributor: "steam", id: "12345" }], + }), + ).toBe("12345"); + }); + + it("rejects non-numeric or non-Steam SKUs", () => { + expect( + steamIdFromEntry({ + id: "game", + name: "Game", + third_party_skus: [{ distributor: "steam", id: "abc" }], + }), + ).toBeNull(); + expect( + steamIdFromEntry({ + id: "game", + name: "Game", + third_party_skus: [{ distributor: "gog", id: "12345" }], + }), + ).toBeNull(); + }); + + it("handles missing entries", () => { + expect(steamIdFromEntry(undefined)).toBeNull(); + }); +}); + +describe("getDetectables", () => { + it("caches the snapshot within the TTL", async () => { + const fetchMock = vi.fn( + async () => + ({ + ok: true, + json: async () => [{ id: "1", name: "Game" }], + }) as Response, + ); + vi.stubGlobal("fetch", fetchMock); + const first = await getDetectables(); + const second = await getDetectables(); + expect(first).toHaveLength(1); + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("falls back to empty when fetch rejects", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + await expect(getDetectables()).resolves.toEqual([]); + }); + + it("falls back to empty on HTTP error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500 }) as Response), + ); + await expect(getDetectables()).resolves.toEqual([]); + }); +}); diff --git a/lib/game/detectables.ts b/lib/game/detectables.ts new file mode 100644 index 0000000..8f75952 --- /dev/null +++ b/lib/game/detectables.ts @@ -0,0 +1,37 @@ +import { warnDev } from "@/lib/log"; +import type { DetectableEntry } from "@/lib/game/game-profile-types"; + +const DETECTABLES_URL = "https://cdn.discordapp.com/detectables/games-v1.json"; +const DETECTABLES_TTL_MS = 86_400_000; + +let detectablesCache: { data: DetectableEntry[]; expires: number } | null = null; + +export function _resetDetectablesCache(): void { + detectablesCache = null; +} + +export async function getDetectables(): Promise { + if (detectablesCache && Date.now() < detectablesCache.expires) { + return detectablesCache.data; + } + try { + const response = await fetch(DETECTABLES_URL); + if (!response.ok) { + warnDev(`detectables failed: ${response.status}`); + return detectablesCache?.data ?? []; + } + const data = (await response.json()) as DetectableEntry[]; + detectablesCache = { data, expires: Date.now() + DETECTABLES_TTL_MS }; + return data; + } catch (error) { + warnDev(`detectables request failed: ${error instanceof Error ? error.message : error}`); + return detectablesCache?.data ?? []; + } +} + +export function steamIdFromEntry(entry: DetectableEntry | undefined): string | null { + const sku = entry?.third_party_skus?.find( + (s) => s.distributor === "steam" && /^\d+$/.test(s.id), + ); + return sku?.id ?? null; +} diff --git a/lib/game/discord-app.test.ts b/lib/game/discord-app.test.ts new file mode 100644 index 0000000..100ed70 --- /dev/null +++ b/lib/game/discord-app.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getDiscordAppInfo } from "@/lib/game/discord-app"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function okRpc(overrides = {}) { + return { + ok: true, + json: async () => ({ id: "123", name: "Game", ...overrides }), + } as Response; +} + +describe("getDiscordAppInfo", () => { + it("returns null for non-numeric ids without fetching", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(getDiscordAppInfo("abc")).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("maps a successful RPC response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okRpc()), + ); + const info = await getDiscordAppInfo("123"); + expect(info?.gameUrl).toBe("https://discord.com/games/123"); + }); + + it("returns null on HTTP error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 404 }) as Response), + ); + await expect(getDiscordAppInfo("123")).resolves.toBeNull(); + }); + + it("throws on network failure", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + await expect(getDiscordAppInfo("123")).rejects.toThrow("discord rpc unreachable"); + }); +}); diff --git a/lib/game/discord-app.ts b/lib/game/discord-app.ts new file mode 100644 index 0000000..d462cb9 --- /dev/null +++ b/lib/game/discord-app.ts @@ -0,0 +1,95 @@ +import { warnDev } from "@/lib/log"; + +export function discordAppIconUrl( + applicationId: string, + iconHash: string | null, + size = 128, +): string | null { + if (!iconHash) { + return null; + } + const extension = iconHash.startsWith("a_") ? "gif" : "png"; + return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`; +} + +export function discordGameUrl(gameId: string): string { + return `https://discord.com/games/${gameId}`; +} + +export function discordAppCoverUrl( + applicationId: string, + coverHash: string | null, + size = 512, +): string | null { + if (!coverHash) { + return null; + } + return `https://cdn.discordapp.com/app-icons/${applicationId}/${coverHash}.png?size=${size}`; +} + +export function steamdbAppUrl(steamId: string): string { + return `https://steamdb.info/app/${steamId}/`; +} + +export interface DiscordAppInfo { + id: string; + name: string; + icon: string | null; + iconUrl: string | null; + coverImage: string | null; + coverUrl: string | null; + gameId: string; + gameUrl: string; +} + +interface DiscordRpcResponse { + id?: string; + name?: string; + icon?: string | null; + cover_image?: string | null; + linked_games?: Array<{ id: string }> | null; +} + +export async function getDiscordAppInfo(applicationId: string): Promise { + if (!/^\d+$/.test(applicationId)) { + return null; + } + let response: Response; + try { + response = await fetch(`https://discord.com/api/v10/applications/${applicationId}/rpc`, { + headers: { "User-Agent": "fristy.net (personal site)" }, + next: { revalidate: 86_400 }, + }); + } catch (error) { + throw new Error(`discord rpc unreachable: ${applicationId}`, { cause: error }); + } + if (!response.ok) { + if (response.status === 404) { + return null; + } + if (response.status >= 500 || response.status === 429) { + throw new Error(`discord rpc upstream failed: ${applicationId} -> ${response.status}`, { + cause: new Error(`HTTP ${response.status}`), + }); + } + warnDev(`discord app failed: ${response.status}`); + return null; + } + let json: DiscordRpcResponse; + try { + json = (await response.json()) as DiscordRpcResponse; + } catch (error) { + throw new Error(`discord rpc malformed response: ${applicationId}`, { cause: error }); + } + const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId; + return { + id: json.id ?? applicationId, + name: json.name ?? "", + icon: json.icon ?? null, + iconUrl: discordAppIconUrl(applicationId, json.icon ?? null), + coverImage: json.cover_image ?? null, + coverUrl: discordAppCoverUrl(applicationId, json.cover_image ?? null), + gameId, + gameUrl: discordGameUrl(gameId), + }; +} diff --git a/lib/game/game-format.test.ts b/lib/game/game-format.test.ts new file mode 100644 index 0000000..e8f1cd9 --- /dev/null +++ b/lib/game/game-format.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GamePricePoint } from "@/lib/game/game-profile-types"; +import { + describeProfile, + discountPercent, + formatCount, + formatElapsed, + formatPrice, + formatReleaseDate, + localizeScore, + normalizeGameTitle, + resolveGameLocale, + selectGameView, + type ModalDict, +} from "@/lib/game/game-format"; + +const dict = { + reviewScores: { + overwhelminglyPositive: "Son Derece Olumlu", + positive: "Olumlu", + mixed: "Karışık", + }, +} as unknown as ModalDict; + +const point: GamePricePoint = { + price: 100, + regular: 200, + currency: "USD", + shop: "Steam", + url: "https://example.com", +}; + +describe("localizeScore", () => { + it("maps known Steam labels", () => { + expect(localizeScore("Overwhelmingly Positive", dict)).toBe("Son Derece Olumlu"); + }); + + it("falls back to the raw label", () => { + expect(localizeScore("Unknown Label", dict)).toBe("Unknown Label"); + }); +}); + +describe("discountPercent", () => { + it("computes the discount", () => { + expect(discountPercent(point)).toBe(50); + }); + + it("returns null without a real discount", () => { + expect(discountPercent({ ...point, regular: 100 })).toBeNull(); + expect(discountPercent({ ...point, regular: 0 })).toBeNull(); + }); +}); + +describe("formatCount", () => { + it("formats with unit", () => { + expect(formatCount(3, "en-US", "reviews")).toBe("3 reviews"); + }); + + it("renders a dash for nullish values", () => { + expect(formatCount(null, "en-US", "reviews")).toBe("—"); + expect(formatCount(undefined, "en-US", "reviews")).toBe("—"); + }); +}); + +describe("formatPrice", () => { + it("formats currency", () => { + expect(formatPrice(point, "en-US")).toContain("100"); + }); +}); + +describe("formatReleaseDate", () => { + it("localizes ISO dates", () => { + expect(formatReleaseDate("2024-01-15", "en-US")).toContain("2024"); + }); + + it("passes through non-ISO values", () => { + expect(formatReleaseDate("soon", "en-US")).toBe("soon"); + }); +}); + +describe("normalizeGameTitle", () => { + it("lowercases and strips marks", () => { + expect(normalizeGameTitle("Elden Ring™")).toBe("elden ring"); + }); + + it("collapses separators", () => { + expect(normalizeGameTitle("Half-Life 2: Episode One")).toBe("half life 2 episode one"); + }); +}); + +describe("resolveGameLocale", () => { + it("resolves supported locales", () => { + expect(resolveGameLocale("tr")).toEqual({ + lang: "tr", + country: "TR", + localeTag: "tr-TR", + steamLang: "turkish", + }); + expect(resolveGameLocale("de").country).toBe("DE"); + }); + + it("falls back to English", () => { + expect(resolveGameLocale("fr").lang).toBe("en"); + expect(resolveGameLocale("").localeTag).toBe("en-US"); + }); +}); + +describe("selectGameView", () => { + it("falls back without a profile", () => { + expect(selectGameView(null, "Name", "https://fallback")).toEqual({ + gameUrl: "https://fallback", + name: "Name", + bestPrice: null, + historyLow: null, + isFree: false, + }); + }); +}); +describe("formatElapsed", () => { + it("formats per-locale units from one table", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-15T12:00:00Z")); + try { + const start = new Date("2024-01-15T10:30:00Z").getTime(); + expect(formatElapsed(start, "tr")).toBe("1s 30dk"); + expect(formatElapsed(start, "de")).toBe("1 Std. 30 Min."); + expect(formatElapsed(start, "en")).toBe("1h 30m"); + expect(formatElapsed(start, "fr")).toBe("1h 30m"); + expect(formatElapsed(Date.now(), "tr")).toBe("<1dk"); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("describeProfile", () => { + it("handles a null profile", () => { + const summary = describeProfile(null); + expect(summary).toEqual({ + dbRating: null, + itadReviews: [], + hasDetails: false, + hasReviews: false, + metascore: null, + opencritic: null, + }); + }); +}); diff --git a/lib/game/game-format.ts b/lib/game/game-format.ts new file mode 100644 index 0000000..0567c06 --- /dev/null +++ b/lib/game/game-format.ts @@ -0,0 +1,211 @@ +import type { GamePricePoint, GameProfile, ItadReview } from "@/lib/game/game-profile-types"; +import type { Dictionary } from "@/lib/i18n/get-dictionary"; +import type { Locale } from "@/lib/i18n/locales"; + +export type ModalDict = Dictionary["discord"]["gameModal"]; + +export const STEAM_SCORE_KEYS: Partial> = { + "Overwhelmingly Positive": "overwhelminglyPositive", + "Very Positive": "veryPositive", + Positive: "positive", + "Mostly Positive": "mostlyPositive", + Mixed: "mixed", + "Mostly Negative": "mostlyNegative", + Negative: "negative", + "Very Negative": "veryNegative", + "Overwhelmingly Negative": "overwhelminglyNegative", +}; + +export const LOCALE_TAGS: Record = { + tr: "tr-TR", + en: "en-US", + de: "de-DE", +}; + +export const COUNTRIES: Record = { + tr: "TR", + en: "US", + de: "DE", +}; + +export function localizeScore(label: string, dict: ModalDict): string { + const key = STEAM_SCORE_KEYS[label]; + return key ? dict.reviewScores[key] : label; +} + +export function formatRatingPercent(value: number, localeTag: string): string { + try { + return new Intl.NumberFormat(localeTag, { + style: "percent", + maximumFractionDigits: 2, + }).format(value); + } catch { + return `%${(value * 100).toFixed(2)}`; + } +} + +export function formatPrice(point: GamePricePoint, localeTag: string): string { + try { + return new Intl.NumberFormat(localeTag, { + style: "currency", + currency: point.currency, + }).format(point.price); + } catch { + return `${point.price} ${point.currency}`; + } +} + +export function formatReleaseDate(value: string, localeTag: string): string { + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const parsed = new Date(`${value}T00:00:00`); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toLocaleDateString(localeTag, { + day: "numeric", + month: "long", + year: "numeric", + }); + } + } + return value; +} + +export function formatCount(value: number | null | undefined, localeTag: string, unit: string): string { + if (value == null) { + return "—"; + } + return `${value.toLocaleString(localeTag)} ${unit}`; +} + +export function discountPercent(point: GamePricePoint): number | null { + if (point.regular > point.price && point.regular > 0) { + return Math.round((1 - point.price / point.regular) * 100); + } + return null; +} + +export function steamdbRating(positive: number, total: number): number | null { + if ( + !Number.isFinite(positive) || + !Number.isFinite(total) || + total <= 0 || + positive < 0 || + positive > total + ) { + return null; + } + const score = positive / total; + return score - (score - 0.5) * 2 ** -Math.log10(total + 1); +} + +export function toIsoDate(unixSeconds: number | undefined): string | null { + if (!unixSeconds) { + return null; + } + return new Date(unixSeconds * 1000).toISOString().slice(0, 10); +} + +export interface GameView { + gameUrl: string; + name: string; + bestPrice: GamePricePoint | null; + historyLow: GamePricePoint | null; + isFree: boolean; +} + +export function selectGameView( + profile: GameProfile | null, + fallbackName: string, + fallbackGameUrl: string, +): GameView { + const bestPrice = profile?.itad?.prices ?? null; + const historyLow = profile?.itad?.historyLow ?? null; + return { + gameUrl: profile?.gameUrl ?? fallbackGameUrl, + name: profile?.name || fallbackName, + bestPrice, + historyLow, + isFree: !!bestPrice && bestPrice.price === 0, + }; +} +export interface ProfileSummary { + dbRating: number | null; + itadReviews: ItadReview[]; + hasDetails: boolean; + hasReviews: boolean; + metascore: ItadReview | null; + opencritic: ItadReview | null; +} + +export function describeProfile(profile: GameProfile | null): ProfileSummary { + const dbRating = + profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null + ? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total) + : null; + const itadReviews = profile?.itad?.reviews ?? []; + const hasDetails = !!( + profile && + (profile.developers.length > 0 || + profile.publishers.length > 0 || + profile.releaseDate || + profile.platforms.length > 0 || + profile.links.length > 0) + ); + const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null; + const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null; + const hasReviews = !!(profile?.steamReviews || metascore || opencritic); + return { dbRating, itadReviews, hasDetails, hasReviews, metascore, opencritic }; +} + +const DEFAULT_ELAPSED_UNITS = { hour: "h", minute: "m", lessThanMinute: "<1m" }; + +const ELAPSED_UNITS: Partial< + Record +> = { + tr: { hour: "s", minute: "dk", lessThanMinute: "<1dk" }, + de: { hour: " Std.", minute: " Min.", lessThanMinute: "<1 Min." }, + en: DEFAULT_ELAPSED_UNITS, +}; + +export function formatElapsed(start: number, lang: string): string { + const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const units = ELAPSED_UNITS[lang] ?? ELAPSED_UNITS.en ?? DEFAULT_ELAPSED_UNITS; + if (hours > 0) { + return `${hours}${units.hour} ${minutes}${units.minute}`; + } + if (minutes > 0) { + return `${minutes}${units.minute}`; + } + return units.lessThanMinute; +} +export function normalizeGameTitle(value: string): string { + return value + .toLowerCase() + .replace(/[®™©]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +export interface GameLocale { + lang: Locale; + country: string; + localeTag: string; + steamLang: string; +} + +const STEAM_LANG: Record = { + tr: "turkish", + en: "english", + de: "german", +}; + +export function resolveGameLocale(lang: string): GameLocale { + const resolved = lang === "tr" || lang === "en" || lang === "de" ? lang : "en"; + return { + lang: resolved, + country: COUNTRIES[resolved], + localeTag: LOCALE_TAGS[resolved], + steamLang: STEAM_LANG[resolved], + }; +} diff --git a/lib/game/game-profile-orchestration.test.ts b/lib/game/game-profile-orchestration.test.ts new file mode 100644 index 0000000..a17458b --- /dev/null +++ b/lib/game/game-profile-orchestration.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getGameProfile } from "@/lib/game/game-profile"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function mockFetch(handler: (url: string, init?: RequestInit) => unknown) { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => handler(url, init)), + ); +} + +function ok(data: unknown) { + return { ok: true, json: async () => data }; +} + +function baseEnv() { + vi.stubGlobal("process", { ...process, env: {} }); +} + +describe("getGameProfile", () => { + it("rejects non-numeric ids without fetching", async () => { + baseEnv(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(getGameProfile("abc")).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("assembles a profile from Steam when providers are absent", async () => { + baseEnv(); + mockFetch((url) => { + if (url.includes("discord.com")) { + return ok({ id: "1", name: "Game" }); + } + if (url.includes("detectables")) { + return ok([ + { + id: "1", + name: "Game", + third_party_skus: [{ distributor: "steam", id: "100" }], + }, + ]); + } + if (url.includes("appdetails")) { + return ok({ + "100": { + success: true, + data: { + name: "Game", + developers: ["Dev"], + publishers: ["Pub"], + platforms: { windows: true, mac: false, linux: false }, + }, + }, + }); + } + if (url.includes("appreviews")) { + return ok({ + query_summary: { + review_score_desc: "Positive", + total_positive: 8, + total_negative: 2, + total_reviews: 10, + }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + const profile = await getGameProfile("1", "en", "US"); + expect(profile?.name).toBe("Game"); + expect(profile?.developers).toEqual(["Dev"]); + expect(profile?.platforms).toContain("Windows"); + expect(profile?.sources).toEqual({ igdb: false, steam: true, itad: false }); + expect(profile?.itad).toBeNull(); + }); + + it("returns null when Discord RPC is missing", async () => { + baseEnv(); + mockFetch((url) => { + if (url.includes("discord.com")) { + return { ok: false, status: 404 }; + } + throw new Error(`unexpected fetch: ${url}`); + }); + await expect(getGameProfile("1")).resolves.toBeNull(); + }); +}); diff --git a/lib/game/game-profile-types.ts b/lib/game/game-profile-types.ts new file mode 100644 index 0000000..4626c3a --- /dev/null +++ b/lib/game/game-profile-types.ts @@ -0,0 +1,141 @@ +export interface GameExternalLink { + label: string; + url: string; +} + +export interface GameReviewSummary { + label: string; + total: number | null; + positive: number | null; + negative: number | null; + url: string; +} + +export interface GamePricePoint { + price: number; + regular: number; + currency: string; + shop: string; + url: string; +} + +export interface ItadReview { + score: number | null; + source: string; + count: number | null; + url: string; +} + +export interface GameProfile { + applicationId: string; + gameId: string; + gameUrl: string; + name: string; + genres: string[]; + description: string | null; + developers: string[]; + publishers: string[]; + releaseDate: string | null; + platforms: string[]; + links: GameExternalLink[]; + coverUrl: string | null; + artworkUrl: string | null; + screenshots: string[]; + steamId: string | null; + steamUrl: string | null; + igdbUrl: string | null; + steamReviews: GameReviewSummary | null; + itad: { + prices: GamePricePoint | null; + historyLow: GamePricePoint | null; + pageUrl: string | null; + playerCount: number | null; + reviews: ItadReview[]; + } | null; + sources: { + igdb: boolean; + steam: boolean; + itad: boolean; + }; +} + +export interface DetectableEntry { + id: string; + name: string; + third_party_skus?: Array<{ distributor: string; id: string }>; +} + +export interface SteamAppDetails { + success: boolean; + data?: { + name?: string; + short_description?: string; + header_image?: string; + genres?: Array<{ description?: string }>; + developers?: string[]; + publishers?: string[]; + platforms?: { windows?: boolean; mac?: boolean; linux?: boolean }; + release_date?: { date?: string }; + screenshots?: Array<{ path_full?: string }>; + }; +} + +export interface SteamReviewQuery { + review_score_desc?: string; + total_positive?: number; + total_negative?: number; + total_reviews?: number; +} + +export interface IgdbGame { + name?: string; + slug?: string; + genres?: Array<{ name?: string }>; + summary?: string; + storyline?: string; + cover?: { url?: string }; + artworks?: Array<{ url?: string }>; + screenshots?: Array<{ url?: string }>; + websites?: Array<{ url?: string; category?: number }>; + involved_companies?: Array<{ + developer?: boolean; + publisher?: boolean; + company?: { name?: string }; + }>; + platforms?: Array<{ abbreviation?: string; name?: string }>; + first_release_date?: number; +} + +export interface ItadGameInfo { + title?: string; + assets?: { boxart?: string; banner600?: string }; + tags?: string[]; + releaseDate?: string | null; + developers?: Array<{ name?: string }>; + publishers?: Array<{ name?: string }>; + reviews?: Array<{ + score?: number | null; + source?: string; + count?: number | null; + url?: string; + }>; + players?: { recent?: number } | null; + urls?: { game?: string }; +} + +export interface ItadOverviewPrice { + current?: { + shop?: { name?: string }; + price?: { amount?: number; currency?: string }; + regular?: { amount?: number }; + cut?: number; + url?: string; + } | null; + lowest?: { + shop?: { name?: string }; + price?: { amount?: number; currency?: string }; + regular?: { amount?: number }; + cut?: number; + url?: string; + } | null; +} diff --git a/lib/game/game-profile.test.ts b/lib/game/game-profile.test.ts new file mode 100644 index 0000000..68de67d --- /dev/null +++ b/lib/game/game-profile.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { buildLinks, mergePeople, mergePlatforms, selectScreenshots } from "@/lib/game/game-profile"; +import type { IgdbGame } from "@/lib/game/game-profile-types"; + +describe("mergePeople", () => { + it("prefers IGDB companies when present", () => { + const igdb = { + involved_companies: [ + { developer: true, company: { name: "Dev Studio" } }, + { publisher: true, company: { name: "Pub Inc" } }, + ], + } as IgdbGame; + const { developers, publishers } = mergePeople(igdb, null, null); + expect(developers).toEqual(["Dev Studio"]); + expect(publishers).toEqual(["Pub Inc"]); + }); + + it("falls back to Steam and ITAD lists", () => { + const steam = { developers: ["Valve"], publishers: ["Valve"] }; + const { developers, publishers } = mergePeople(null, steam, null); + expect(developers).toEqual(["Valve"]); + expect(publishers).toEqual(["Valve"]); + }); + + it("returns empty lists without sources", () => { + expect(mergePeople(null, null, null)).toEqual({ developers: [], publishers: [] }); + }); +}); + +describe("mergePlatforms", () => { + it("normalizes IGDB platform names", () => { + const igdb = { + platforms: [{ name: "PC (Microsoft Windows)" }, { name: "Mac" }, { name: "Switch" }], + } as IgdbGame; + expect(mergePlatforms(igdb, null, undefined)).toEqual(["Windows", "MacOS", "Switch"]); + }); + + it("merges IGDB, Steam, and SKU platforms without early exit", () => { + const igdb = { platforms: [{ name: "Switch" }] } as IgdbGame; + const steam = { platforms: { windows: true, mac: false, linux: false } }; + const entry = { id: "g", name: "G", third_party_skus: [{ distributor: "xbox", id: "1" }] }; + expect(mergePlatforms(igdb, steam, entry)).toEqual(["Switch", "Windows", "Xbox"]); + }); + + it("still resolves SKUs when Steam data is missing", () => { + const entry = { + id: "g", + name: "G", + third_party_skus: [{ distributor: "playstation", id: "1" }], + }; + expect(mergePlatforms(null, null, entry)).toEqual(["PlayStation"]); + }); +}); + +describe("buildLinks", () => { + it("labels known categories and dedupes", () => { + const igdb = { + websites: [ + { url: "https://store.steampowered.com/app/1", category: 13 }, + { url: "https://store.steampowered.com/app/1", category: 13 }, + { url: "example.com", category: 99 }, + ], + } as IgdbGame; + const links = buildLinks(igdb); + expect(links).toHaveLength(2); + expect(links[0]).toEqual({ + label: "Steam", + url: "https://store.steampowered.com/app/1", + }); + expect(links[1].label).toBe("example.com"); + }); + + it("returns no links without websites", () => { + expect(buildLinks(null)).toEqual([]); + }); +}); + +describe("selectScreenshots", () => { + it("prefers IGDB shots and caps at 8", () => { + const igdb = { + screenshots: Array.from({ length: 10 }, (_, i) => ({ + url: `//images.igdb.com/t_thumb/${i}.jpg`, + })), + } as IgdbGame; + const shots = selectScreenshots(igdb, null); + expect(shots).toHaveLength(8); + expect(shots[0]).toContain("t_screenshot_big"); + }); + + it("falls back to Steam shots", () => { + const steam = { screenshots: [{ path_full: "https://cdn/shot.jpg" }] }; + expect(selectScreenshots(null, steam)).toEqual(["https://cdn/shot.jpg"]); + }); +}); diff --git a/lib/game/game-profile.ts b/lib/game/game-profile.ts new file mode 100644 index 0000000..4c25e52 --- /dev/null +++ b/lib/game/game-profile.ts @@ -0,0 +1,287 @@ +import { getDiscordAppInfo } from "@/lib/game/discord-app"; +import { getDetectables, steamIdFromEntry } from "@/lib/game/detectables"; +import { getSteamDetails, getSteamReviewSummary } from "@/lib/game/steam"; +import { getIgdbGame, igdbImage } from "@/lib/game/igdb"; +import { getItadId, itadFetch } from "@/lib/game/itad"; +import { toIsoDate } from "@/lib/game/game-format"; +import type { + GameExternalLink, + GamePricePoint, + GameProfile, + IgdbGame, + ItadGameInfo, + ItadOverviewPrice, + ItadReview, + SteamAppDetails, +} from "@/lib/game/game-profile-types"; +import type { DetectableEntry } from "@/lib/game/game-profile-types"; + +const DISTRIBUTOR_PLATFORM: Partial> = { + xbox: "Xbox", + xbox_title: "Xbox", + xbox_game_pass: "Xbox", + playstation: "PlayStation", + nintendo: "Switch", +}; + +const IGDB_WEBSITE_LABELS: Record = { + 1: "Website", + 4: "Facebook", + 5: "X", + 6: "Twitch", + 8: "Instagram", + 9: "YouTube", + 13: "Steam", + 14: "Reddit", + 16: "Epic", + 17: "GOG", + 18: "Discord", +}; + +type SteamData = SteamAppDetails["data"]; + +export function mergePeople( + igdb: IgdbGame | null, + steam: SteamData | null, + itadInfo: ItadGameInfo | null, +): { developers: string[]; publishers: string[] } { + const companies = igdb?.involved_companies ?? []; + if (companies.length === 0) { + const steamDevs = steam?.developers?.length + ? steam.developers + : itadInfo?.developers?.map((d) => d.name).filter((n): n is string => !!n) || []; + const steamPubs = steam?.publishers?.length + ? steam.publishers + : itadInfo?.publishers?.map((d) => d.name).filter((n): n is string => !!n) || []; + return { developers: steamDevs, publishers: steamPubs }; + } + const names = (developer: boolean): string[] => [ + ...new Set( + companies + .filter((c) => (developer ? c.developer : c.publisher)) + .map((c) => c.company?.name) + .filter((n): n is string => !!n), + ), + ]; + return { developers: names(true), publishers: names(false) }; +} + +export function mergePlatforms( + igdb: IgdbGame | null, + steam: SteamData | null, + entry: DetectableEntry | undefined, +): string[] { + const platforms = new Set(); + for (const p of igdb?.platforms ?? []) { + const raw = (p.name ?? "").trim(); + if (!raw) { + continue; + } + if (raw === "PC (Microsoft Windows)") { + platforms.add("Windows"); + } else if (raw === "Mac") { + platforms.add("MacOS"); + } else { + platforms.add(raw); + } + } + if (steam?.platforms) { + if (steam.platforms.windows) { + platforms.add("Windows"); + } + if (steam.platforms.mac) { + platforms.add("MacOS"); + } + if (steam.platforms.linux) { + platforms.add("Linux"); + } + } + for (const sku of entry?.third_party_skus ?? []) { + const mapped = DISTRIBUTOR_PLATFORM[sku.distributor]; + if (mapped) { + platforms.add(mapped); + } + } + return [...platforms]; +} + +export function buildLinks(igdb: IgdbGame | null): GameExternalLink[] { + const links: GameExternalLink[] = []; + for (const site of igdb?.websites ?? []) { + if (!site.url) { + continue; + } + const url = site.url.startsWith("http") ? site.url : `https://${site.url}`; + let parsed: URL | null = null; + try { + parsed = new URL(url); + } catch { + continue; + } + if (parsed.protocol !== "https:") { + continue; + } + let label = site.category ? (IGDB_WEBSITE_LABELS[site.category] ?? null) : null; + if (!label) { + label = parsed.hostname.replace(/^www\./, ""); + } + if (!links.some((l) => l.url === url)) { + links.push({ label, url }); + } + } + return links; +} + +export function selectScreenshots(igdb: IgdbGame | null, steam: SteamData | null): string[] { + return [ + ...(igdb?.screenshots ?? []) + .map((s) => igdbImage(s.url, "t_screenshot_big")) + .filter((u): u is string => !!u), + ...(steam?.screenshots ?? []).map((s) => s.path_full).filter((u): u is string => !!u), + ].slice(0, 8); +} + +export function buildGenres(igdb: IgdbGame | null, itadInfo: ItadGameInfo | null): string[] { + return [ + ...(igdb?.genres ?? []).map((g) => g.name).filter((n): n is string => !!n), + ...(igdb?.genres?.some((g) => !!g.name) ? [] : (itadInfo?.tags ?? []).slice(0, 5)), + ].slice(0, 6); +} + +export function selectDescription( + lang: string, + igdb: IgdbGame | null, + steam: SteamData | null, +): string | null { + return lang === "en" + ? (igdb?.summary ?? steam?.short_description ?? null) + : (steam?.short_description ?? igdb?.summary ?? null); +} + +export function selectArtwork( + igdb: IgdbGame | null, + steam: SteamData | null, + itadInfo: ItadGameInfo | null, +): string | null { + return ( + igdbImage(igdb?.cover?.url, "t_cover_big") ?? + steam?.header_image ?? + itadInfo?.assets?.banner600 ?? + null + ); +} + +export function toPricePoint(p: ItadOverviewPrice["current"]): GamePricePoint | null { + if (!p?.price || typeof p.price.amount !== "number") { + return null; + } + return { + price: p.price.amount, + regular: p.regular?.amount ?? p.price.amount, + currency: p.price.currency ?? "", + shop: p.shop?.name ?? "", + url: p.url?.startsWith("https://") ? p.url : "", + }; +} + +export function mapItadBlock( + itadInfo: ItadGameInfo | null, + priceEntry: { current?: ItadOverviewPrice["current"]; lowest?: ItadOverviewPrice["current"] } | undefined, +): NonNullable | null { + if (!process.env.ITAD_API_KEY || (!itadInfo && !priceEntry)) { + return null; + } + return { + prices: toPricePoint(priceEntry?.current), + historyLow: toPricePoint(priceEntry?.lowest), + pageUrl: itadInfo?.urls?.game ?? null, + playerCount: itadInfo?.players?.recent ?? null, + reviews: (itadInfo?.reviews ?? []) + .filter( + (r): r is ItadReview & { source: string; url: string } => + !!r.source && !!r.url, + ) + .map((r) => ({ + score: r.score ?? null, + source: r.source, + count: r.count ?? null, + url: r.url, + })), + }; +} + +export async function getGameProfile( + applicationId: string, + lang = "en", + country = "TR", +): Promise { + if (!/^\d+$/.test(applicationId)) { + return null; + } + const appInfo = await getDiscordAppInfo(applicationId); + if (!appInfo) { + return null; + } + + const detectables = await getDetectables(); + const entry = detectables.find((e) => e.id === appInfo.gameId || e.id === applicationId); + const steamId = steamIdFromEntry(entry); + + const [steam, steamReviews, igdb] = await Promise.all([ + steamId ? getSteamDetails(steamId, lang, country) : Promise.resolve(null), + steamId ? getSteamReviewSummary(steamId) : Promise.resolve(null), + getIgdbGame(appInfo.name || entry?.name || "", steamId), + ]); + + const itadId = process.env.ITAD_API_KEY + ? await getItadId(igdb?.name ?? steam?.name ?? appInfo.name) + : null; + const [itadInfo, itadOverview] = itadId + ? await Promise.all([ + itadFetch(`/games/info/v2?id=${itadId}`), + itadFetch<{ prices?: ItadOverviewPrice[] }>(`/games/overview/v2?country=${country}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify([itadId]), + }), + ]) + : [null, null]; + + const { developers, publishers } = mergePeople(igdb, steam, itadInfo); + const platforms = mergePlatforms(igdb, steam, entry); + const links = buildLinks(igdb); + const screenshots = selectScreenshots(igdb, steam); + + const priceEntry = itadOverview?.prices?.[0]; + + return { + applicationId, + gameId: appInfo.gameId, + gameUrl: appInfo.gameUrl, + name: igdb?.name ?? steam?.name ?? appInfo.name, + genres: buildGenres(igdb, itadInfo), + description: selectDescription(lang, igdb, steam), + developers, + publishers, + releaseDate: + toIsoDate(igdb?.first_release_date) ?? + itadInfo?.releaseDate ?? + steam?.release_date?.date ?? + null, + platforms, + links, + coverUrl: igdbImage(igdb?.cover?.url, "t_cover_big"), + artworkUrl: selectArtwork(igdb, steam, itadInfo), + screenshots, + steamId, + steamUrl: steamId ? `https://store.steampowered.com/app/${steamId}/` : null, + igdbUrl: igdb?.slug ? `https://www.igdb.com/games/${igdb.slug}` : null, + steamReviews, + itad: mapItadBlock(itadInfo, priceEntry), + sources: { + igdb: !!igdb, + steam: !!steam, + itad: !!(itadInfo || priceEntry), + }, + }; +} diff --git a/lib/game/igdb.test.ts b/lib/game/igdb.test.ts new file mode 100644 index 0000000..db57a32 --- /dev/null +++ b/lib/game/igdb.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { _resetIgdbState, getIgdbGame, igdbImage } from "@/lib/game/igdb"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + _resetIgdbState(); +}); + +function tokenResponse() { + return { + ok: true, + json: async () => ({ access_token: "tok", expires_in: 3600 }), + } as Response; +} + +describe("igdbImage", () => { + it("upgrades thumbnail sizes", () => { + expect(igdbImage("//images.igdb.com/t_thumb/1.jpg", "t_cover_big")).toBe( + "https://images.igdb.com/t_cover_big/1.jpg", + ); + }); + + it("returns null without a url", () => { + expect(igdbImage(undefined, "t_cover_big")).toBeNull(); + }); +}); + +describe("getIgdbGame", () => { + it("returns null without credentials and warns once", async () => { + vi.stubGlobal("process", { ...process, env: {} }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(getIgdbGame("Game", null)).resolves.toBeNull(); + await expect(getIgdbGame("Game", null)).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns null when the token fetch rejects", async () => { + vi.stubGlobal("process", { + ...process, + env: { TWITCH_CLIENT_ID: "id", TWITCH_CLIENT_SECRET: "secret" }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + await expect(getIgdbGame("Game", null)).resolves.toBeNull(); + }); + + it("escapes backslashes and quotes in search queries", async () => { + vi.stubGlobal("process", { + ...process, + env: { TWITCH_CLIENT_ID: "id", TWITCH_CLIENT_SECRET: "secret" }, + }); + const bodies: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_url: string, init?: RequestInit) => { + if (typeof init?.body === "string" && init.body.includes("external_games")) { + return { ok: true, json: async () => [] } as Response; + } + if (typeof init?.body === "string") { + bodies.push(init.body); + return { ok: true, json: async () => [] } as Response; + } + return tokenResponse(); + }), + ); + await expect(getIgdbGame('a\\b"c', null)).resolves.toBeNull(); + expect(bodies.some((b) => b.includes('a\\\\b\\"c'))).toBe(true); + }); +}); diff --git a/lib/game/igdb.ts b/lib/game/igdb.ts new file mode 100644 index 0000000..e4b0083 --- /dev/null +++ b/lib/game/igdb.ts @@ -0,0 +1,139 @@ +import type { IgdbGame } from "@/lib/game/game-profile-types"; +import { normalizeGameTitle } from "@/lib/game/game-format"; +import { warnDev } from "@/lib/log"; + +const IGDB_GAME_FIELDS = + "name,slug,genres.name,summary,storyline,cover.url,artworks.url,screenshots.url," + + "websites.url,websites.category,involved_companies.developer," + + "involved_companies.publisher,involved_companies.company.name," + + "platforms.abbreviation,platforms.name,first_release_date;"; + +const TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token"; +const TOKEN_FALLBACK_EXPIRY_S = 3600; +const MS_PER_SECOND = 1000; +const TOKEN_EXPIRY_SKEW_MS = 60_000; + +let igdbTokenCache: { token: string; expires: number } | null = null; + +let igdbKeyWarned = false; + +export function _resetIgdbState(): void { + igdbTokenCache = null; + igdbKeyWarned = false; +} + +async function getIgdbToken(): Promise { + const clientId = process.env.TWITCH_CLIENT_ID; + const clientSecret = process.env.TWITCH_CLIENT_SECRET; + if (!clientId || !clientSecret) { + if (!igdbKeyWarned) { + igdbKeyWarned = true; + warnDev("igdb not configured"); + } + return null; + } + if (igdbTokenCache && Date.now() < igdbTokenCache.expires) { + return igdbTokenCache.token; + } + try { + const response = await fetch(TWITCH_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + grant_type: "client_credentials", + }), + }); + if (!response.ok) { + warnDev(`igdb token failed: ${response.status}`); + return null; + } + const json = (await response.json()) as { access_token?: string; expires_in?: number }; + if (!json.access_token) { + return null; + } + igdbTokenCache = { + token: json.access_token, + expires: + Date.now() + + (json.expires_in ?? TOKEN_FALLBACK_EXPIRY_S) * MS_PER_SECOND - + TOKEN_EXPIRY_SKEW_MS, + }; + return json.access_token; + } catch (error) { + warnDev(`igdb token request failed: ${error instanceof Error ? error.message : error}`); + return null; + } +} + +async function igdbQuery(token: string, endpoint: string, body: string): Promise { + try { + const response = await fetch(`https://api.igdb.com/v4/${endpoint}`, { + method: "POST", + headers: { + "Client-ID": process.env.TWITCH_CLIENT_ID ?? "", + Authorization: `Bearer ${token}`, + "Content-Type": "text/plain", + }, + body, + next: { revalidate: 86_400 }, + }); + if (!response.ok) { + warnDev(`igdb query failed: ${response.status}`); + return null; + } + return (await response.json()) as T; + } catch (error) { + warnDev(`igdb query request failed: ${error instanceof Error ? error.message : error}`); + return null; + } +} + +export function igdbImage(url: string | undefined, size: string): string | null { + if (!url) { + return null; + } + const full = url.startsWith("//") ? `https:${url}` : url; + return full.replace(/t_thumb|t_cover_small|t_logo_med/, size); +} + +export async function getIgdbGame(name: string, steamId: string | null): Promise { + const token = await getIgdbToken(); + if (!token) { + return null; + } + if (steamId) { + const external = await igdbQuery>( + token, + "external_games", + `fields game; where uid = "${steamId}"; limit 1;`, + ); + const gameId = external?.[0]?.game; + if (gameId) { + const games = await igdbQuery( + token, + "games", + `fields ${IGDB_GAME_FIELDS} where id = ${gameId}; limit 1;`, + ); + if (games?.[0]) { + return games[0]; + } + } + } + const cleaned = name + .replace(/[®™©]/g, "") + .replace(/\s{2,}/g, " ") + .trim(); + const escaped = cleaned.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const games = await igdbQuery( + token, + "games", + `fields ${IGDB_GAME_FIELDS} search "${escaped}"; limit 5;`, + ); + if (!games || games.length === 0) { + return null; + } + const wanted = normalizeGameTitle(name); + return games.find((g) => g.name && normalizeGameTitle(g.name) === wanted) ?? games[0] ?? null; +} diff --git a/lib/game/itad.test.ts b/lib/game/itad.test.ts new file mode 100644 index 0000000..9521382 --- /dev/null +++ b/lib/game/itad.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { _resetItadWarning, getItadId, itadFetch } from "@/lib/game/itad"; +import { toIsoDate } from "@/lib/game/game-format"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + _resetItadWarning(); +}); + +describe("toIsoDate", () => { + it("converts unix seconds to ISO dates", () => { + expect(toIsoDate(1705276800)).toBe("2024-01-15"); + }); + + it("returns null for missing values", () => { + expect(toIsoDate(undefined)).toBeNull(); + expect(toIsoDate(0)).toBeNull(); + }); +}); + +describe("itadFetch", () => { + it("returns null without an API key and without fetching", async () => { + vi.stubGlobal("process", { ...process, env: {} }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(itadFetch("/games/info/v2")).resolves.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("returns data on success and null on HTTP error", async () => { + vi.stubGlobal("process", { ...process, env: { ITAD_API_KEY: "k" } }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ id: "1" }) }) as Response), + ); + await expect(itadFetch("/games/info/v2")).resolves.toEqual({ id: "1" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + await expect(itadFetch("/games/info/v2")).resolves.toBeNull(); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 429 }) as Response), + ); + await expect(itadFetch("/games/info/v2")).resolves.toBeNull(); + }); +}); + +describe("getItadId", () => { + it("matches normalized titles", async () => { + vi.stubGlobal("process", { ...process, env: { ITAD_API_KEY: "k" } }); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + ({ + ok: true, + json: async () => [{ id: "7", title: "Elden Ring" }], + }) as Response, + ), + ); + await expect(getItadId("ELDEN RING")).resolves.toBe("7"); + await expect(getItadId("Unknown")).resolves.toBeNull(); + }); +}); diff --git a/lib/game/itad.ts b/lib/game/itad.ts new file mode 100644 index 0000000..12bb332 --- /dev/null +++ b/lib/game/itad.ts @@ -0,0 +1,48 @@ +import { normalizeGameTitle } from "@/lib/game/game-format"; +import { warnDev } from "@/lib/log"; + +let itadKeyWarned = false; + +export function _resetItadWarning(): void { + itadKeyWarned = false; +} + +export async function itadFetch(path: string, init?: RequestInit): Promise { + const key = process.env.ITAD_API_KEY; + if (!key) { + if (!itadKeyWarned) { + itadKeyWarned = true; + warnDev("itad not configured"); + } + return null; + } + try { + const separator = path.includes("?") ? "&" : "?"; + const response = await fetch( + `https://api.isthereanydeal.com${path}${separator}key=${key}`, + { + ...init, + next: { revalidate: 3600 }, + }, + ); + if (!response.ok) { + warnDev(`itad failed: ${response.status}`); + return null; + } + return (await response.json()) as T; + } catch (error) { + warnDev(`itad request failed: ${error instanceof Error ? error.message : error}`); + return null; + } +} + +export async function getItadId(title: string): Promise { + const results = await itadFetch>( + `/games/search/v1?title=${encodeURIComponent(title)}&results=5`, + ); + if (!results || results.length === 0) { + return null; + } + const wanted = normalizeGameTitle(title); + return results.find((r) => r.title && normalizeGameTitle(r.title) === wanted)?.id ?? null; +} diff --git a/lib/game/lanyard.test.ts b/lib/game/lanyard.test.ts new file mode 100644 index 0000000..2529d75 --- /dev/null +++ b/lib/game/lanyard.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + activityImageUrl, + discordAvatarUrl, + formatSpotifyList, + getLanyard, + spotifyTrackUrl, + type LanyardActivity, +} from "@/lib/game/lanyard"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function activity(overrides: Partial = {}): LanyardActivity { + return { id: "1", name: "Game", type: 0, ...overrides }; +} + +describe("activityImageUrl", () => { + it("resolves external media keys", () => { + expect(activityImageUrl(activity({ assets: { large_image: "mp:abc" } }), "large")).toBe( + "https://media.discordapp.net/external/abc", + ); + }); + + it("resolves Spotify keys", () => { + expect(activityImageUrl(activity({ assets: { large_image: "spotify:img" } }), "large")).toBe( + "https://i.scdn.co/image/img", + ); + }); + + it("resolves app assets with application id", () => { + expect( + activityImageUrl( + activity({ application_id: "9", assets: { large_image: "art" } }), + "large", + ), + ).toBe("https://cdn.discordapp.com/app-assets/9/art.png"); + }); + + it("returns null without a key", () => { + expect(activityImageUrl(activity(), "large")).toBeNull(); + }); + + it("picks small vs large art", () => { + const withBoth = activity({ + assets: { large_image: "mp:big", small_image: "mp:small" }, + }); + expect(activityImageUrl(withBoth, "small")).toContain("small"); + expect(activityImageUrl(withBoth, "large")).toContain("big"); + }); +}); + +describe("formatSpotifyList", () => { + it("normalizes separators", () => { + expect(formatSpotifyList("a;b, ,c")).toBe("a,b,c"); + expect(formatSpotifyList("a b")).toBe("a b"); + }); + + it("returns empty for nullish input", () => { + expect(formatSpotifyList(null)).toBe(""); + expect(formatSpotifyList(undefined)).toBe(""); + }); +}); + +describe("spotifyTrackUrl", () => { + it("builds the track URL", () => { + expect(spotifyTrackUrl("xyz")).toBe("https://open.spotify.com/track/xyz"); + }); +}); + +describe("discordAvatarUrl", () => { + it("falls back to the default avatar", () => { + expect(discordAvatarUrl("1", null)).toContain("embed/avatars/0.png"); + }); + + it("picks gif for animated hashes", () => { + expect(discordAvatarUrl("1", "a_hash")).toContain(".gif"); + expect(discordAvatarUrl("1", "hash")).toContain(".png"); + }); +}); + +describe("getLanyard", () => { + it("returns data on success and null otherwise", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ success: true, data: {} }) })), + ); + await expect(getLanyard()).resolves.toEqual({}); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ success: false }) })), + ); + await expect(getLanyard()).resolves.toBeNull(); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500 }) as Response), + ); + await expect(getLanyard()).resolves.toBeNull(); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + await expect(getLanyard()).resolves.toBeNull(); + }); +}); diff --git a/lib/lanyard.ts b/lib/game/lanyard.ts similarity index 57% rename from lib/lanyard.ts rename to lib/game/lanyard.ts index c637d85..868c5c3 100644 --- a/lib/lanyard.ts +++ b/lib/game/lanyard.ts @@ -1,6 +1,8 @@ +import { warnDev } from "@/lib/log"; + export const DISCORD_ID = "1014549805608992901"; -const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`; +export const LANYARD_API = `https://api.lanyard.rest/v1/users/${DISCORD_ID}`; export const LANYARD_WS = "wss://api.lanyard.rest/socket"; @@ -31,8 +33,13 @@ export interface LanyardActivity { assets?: LanyardActivityAssets | null; } -export function activityImageUrl(activity: LanyardActivity, small: boolean): string | null { - const key = small ? activity.assets?.small_image : activity.assets?.large_image; +export type ActivityImageSize = "small" | "large"; + +export function activityImageUrl( + activity: LanyardActivity, + size: ActivityImageSize = "large", +): string | null { + const key = size === "small" ? activity.assets?.small_image : activity.assets?.large_image; if (!key) { return null; } @@ -75,75 +82,6 @@ export function spotifyTrackUrl(trackId: string): string { return `https://open.spotify.com/track/${trackId}`; } -export function discordAppIconUrl( - applicationId: string, - iconHash: string | null, - size = 128, -): string | null { - if (!iconHash) { - return null; - } - const extension = iconHash.startsWith("a_") ? "gif" : "png"; - return `https://cdn.discordapp.com/app-icons/${applicationId}/${iconHash}.${extension}?size=${size}`; -} - -export function discordGameUrl(gameId: string): string { - return `https://discord.com/games/${gameId}`; -} - -export interface DiscordAppInfo { - id: string; - name: string; - icon: string | null; - iconUrl: string | null; - coverImage: string | null; - coverUrl: string | null; - gameId: string; - gameUrl: string; -} - -interface DiscordRpcResponse { - id: string; - name: string; - icon?: string | null; - cover_image?: string | null; - linked_games?: Array<{ id: string }> | null; -} - -export async function getDiscordAppInfo(applicationId: string): Promise { - if (!/^\d+$/.test(applicationId)) { - return null; - } - try { - const response = await fetch( - `https://discord.com/api/v10/applications/${applicationId}/rpc`, - { - headers: { "User-Agent": "fristy.net (personal site)" }, - next: { revalidate: 86_400 }, - }, - ); - if (!response.ok) { - return null; - } - const json = (await response.json()) as DiscordRpcResponse; - const gameId = json.linked_games?.[0]?.id ?? json.id ?? applicationId; - return { - id: json.id ?? applicationId, - name: json.name ?? "", - icon: json.icon ?? null, - iconUrl: discordAppIconUrl(applicationId, json.icon ?? null), - coverImage: json.cover_image ?? null, - coverUrl: json.cover_image - ? `https://cdn.discordapp.com/app-icons/${applicationId}/${json.cover_image}.png?size=512` - : null, - gameId, - gameUrl: discordGameUrl(gameId), - }; - } catch { - return null; - } -} - export interface LanyardData { discord_user: { id: string; @@ -172,19 +110,17 @@ export async function getLanyard(): Promise { try { const response = await fetch(LANYARD_API, { next: { revalidate: 10 } }); if (!response.ok) { + warnDev(`lanyard failed: ${response.status}`); return null; } const json = (await response.json()) as LanyardResponse; return json.success ? json.data : null; - } catch { + } catch (error) { + warnDev(`lanyard request failed: ${error instanceof Error ? error.message : error}`); return null; } } -export function lanyardUrl(): string { - return LANYARD_API; -} - export function discordAvatarUrl(userId: string, avatarHash: string | null, size = 128): string { if (!avatarHash) { return `https://cdn.discordapp.com/embed/avatars/0.png?size=${size}`; diff --git a/lib/game/steam.test.ts b/lib/game/steam.test.ts new file mode 100644 index 0000000..4b08181 --- /dev/null +++ b/lib/game/steam.test.ts @@ -0,0 +1,108 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getSteamDetails, getSteamReviewSummary } from "@/lib/game/steam"; +import { steamdbRating } from "@/lib/game/game-format"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("steamdbRating", () => { + it("returns null for invalid input", () => { + expect(steamdbRating(5, 0)).toBeNull(); + expect(steamdbRating(-1, 10)).toBeNull(); + expect(steamdbRating(11, 10)).toBeNull(); + expect(steamdbRating(Number.NaN, 10)).toBeNull(); + }); + + it("pulls low-count ratios toward 50%", () => { + const rating = steamdbRating(9, 10); + expect(rating).not.toBeNull(); + expect(rating as number).toBeLessThan(0.9); + expect(rating as number).toBeGreaterThan(0.5); + }); + + it("converges to the raw ratio with many reviews", () => { + const rating = steamdbRating(900000, 1000000); + expect(rating).not.toBeNull(); + expect(Math.abs((rating as number) - 0.9)).toBeLessThan(0.01); + }); + + it("rates unanimous positives below 100%", () => { + const rating = steamdbRating(10, 10); + expect(rating).not.toBeNull(); + expect(rating as number).toBeLessThan(1); + }); +}); + +describe("getSteamDetails", () => { + it("returns entry data on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + ({ + ok: true, + json: async () => ({ "100": { success: true, data: { name: "Game" } } }), + }) as Response, + ), + ); + await expect(getSteamDetails("100")).resolves.toEqual({ name: "Game" }); + }); + + it("returns null when fetch rejects", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("down"); + }), + ); + await expect(getSteamDetails("100")).resolves.toBeNull(); + }); + + it("returns null on failed entries and HTTP errors", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ "100": { success: false } }) })), + ); + await expect(getSteamDetails("100")).resolves.toBeNull(); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500 }) as Response), + ); + await expect(getSteamDetails("100")).resolves.toBeNull(); + }); +}); + +describe("getSteamReviewSummary", () => { + it("maps the query summary", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + ({ + ok: true, + json: async () => ({ + query_summary: { + review_score_desc: "Positive", + total_positive: 9, + total_negative: 1, + total_reviews: 10, + }, + }), + }) as Response, + ), + ); + const summary = await getSteamReviewSummary("100"); + expect(summary?.total).toBe(10); + expect(summary?.url).toContain("100"); + }); + + it("returns null without a numeric total", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => ({ query_summary: {} }) })), + ); + await expect(getSteamReviewSummary("100")).resolves.toBeNull(); + }); +}); diff --git a/lib/game/steam.ts b/lib/game/steam.ts new file mode 100644 index 0000000..23881de --- /dev/null +++ b/lib/game/steam.ts @@ -0,0 +1,58 @@ +import type { + GameReviewSummary, + SteamAppDetails, + SteamReviewQuery, +} from "@/lib/game/game-profile-types"; +import { resolveGameLocale } from "@/lib/game/game-format"; +import { warnDev } from "@/lib/log"; + +export async function getSteamDetails( + steamId: string, + lang = "en", + country = "US", +): Promise { + try { + const { steamLang } = resolveGameLocale(lang); + const response = await fetch( + `https://store.steampowered.com/api/appdetails?appids=${steamId}&l=${steamLang}&cc=${country}`, + { next: { revalidate: 86_400 } }, + ); + if (!response.ok) { + warnDev(`steam details failed: ${response.status}`); + return null; + } + const json = (await response.json()) as Record; + const entry = json[steamId]; + return entry?.success ? (entry.data ?? null) : null; + } catch (error) { + warnDev(`steam details request failed: ${error instanceof Error ? error.message : error}`); + return null; + } +} +export async function getSteamReviewSummary(steamId: string): Promise { + try { + const url = + `https://store.steampowered.com/appreviews/${steamId}` + + "?json=1&language=all&purchase_type=all&num_per_page=0"; + const response = await fetch(url, { next: { revalidate: 3600 } }); + if (!response.ok) { + warnDev(`steam reviews failed: ${response.status}`); + return null; + } + const json = (await response.json()) as { query_summary?: SteamReviewQuery }; + const summary = json.query_summary; + if (!summary || typeof summary.total_reviews !== "number") { + return null; + } + return { + label: summary.review_score_desc ?? "", + total: summary.total_reviews, + positive: summary.total_positive ?? null, + negative: summary.total_negative ?? null, + url: `https://store.steampowered.com/app/${steamId}/#app_reviews_hash`, + }; + } catch (error) { + warnDev(`steam reviews request failed: ${error instanceof Error ? error.message : error}`); + return null; + } +} diff --git a/lib/gitea.test.ts b/lib/gitea.test.ts new file mode 100644 index 0000000..865c5d9 --- /dev/null +++ b/lib/gitea.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getRepos } from "@/lib/gitea"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function apiRepo(overrides: Record) { + return { + id: 0, + name: "repo", + full_name: "fristy/repo", + description: null, + language: null, + html_url: "https://git.fristy.net/fristy/repo", + website: "", + stars_count: 0, + forks_count: 0, + updated_at: "2024-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("getRepos", () => { + it("maps, filters, and sorts repos", async () => { + const payload = [ + apiRepo({ id: 2, name: "old", full_name: "fristy/old", language: "TypeScript" }), + apiRepo({ id: 1, name: ".profile", updated_at: "2025-01-01T00:00:00Z" }), + apiRepo({ + id: 3, + name: "new", + full_name: "fristy/new", + description: "fresh", + language: "Go", + website: "https://example.com", + stars_count: 5, + forks_count: 2, + updated_at: "2026-01-01T00:00:00Z", + }), + ]; + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, json: async () => payload })), + ); + const repos = await getRepos(); + expect(repos.map((r) => r.name)).toEqual(["new", "old"]); + expect(repos[0]?.website).toBe("https://example.com"); + expect(repos[1]?.website).toBeNull(); + }); + + it("returns empty on HTTP error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 500 })), + ); + await expect(getRepos()).resolves.toEqual([]); + }); +}); diff --git a/lib/gitea.ts b/lib/gitea.ts index 5b14cfa..e982bbf 100644 --- a/lib/gitea.ts +++ b/lib/gitea.ts @@ -1,3 +1,5 @@ +import { warnDev } from "@/lib/log"; + const GITEA_API = "https://git.fristy.net/api/v1"; const GITEA_USER = "fristy"; @@ -36,7 +38,7 @@ export async function getRepos(): Promise { { next: { revalidate: 3600 } }, ); if (!response.ok) { - console.error(`cannot fetch gitea repos: ${response.status}`); + warnDev(`cannot fetch gitea repos: ${response.status}`); return []; } const data = (await response.json()) as GiteaApiRepo[]; @@ -55,8 +57,8 @@ export async function getRepos(): Promise { updatedAt: repo.updated_at, })) .sort((a, b) => +new Date(b.updatedAt) - +new Date(a.updatedAt)); - } catch (error) { - console.error("cannot fetch gitea repos:", error); + } catch { + warnDev("cannot fetch gitea repos"); return []; } } diff --git a/lib/i18n/dictionaries/de.json b/lib/i18n/dictionaries/de.json index c70cd21..2c982e2 100644 --- a/lib/i18n/dictionaries/de.json +++ b/lib/i18n/dictionaries/de.json @@ -49,11 +49,12 @@ "loadError": "Spieldaten konnten nicht geladen werden.", "retry": "Erneut versuchen", "partialFail": "Einige Daten konnten nicht geladen werden.", + "noGameData": "Für diese App wurden keine Spieldaten gefunden.", "screenshots": "Screenshots", "reviewsUnit": "Bewertungen", "scrollBack": "Zurück", "scrollForward": "Weiter", - "totalReviews": "Bewertungen insgesamt", + "totalReviews": "Steam", "steamdbRating": "SteamDB-Wertung", "reviewScores": { "overwhelminglyPositive": "Äußerst positiv", diff --git a/lib/i18n/dictionaries/en.json b/lib/i18n/dictionaries/en.json index 906dff1..14f696a 100644 --- a/lib/i18n/dictionaries/en.json +++ b/lib/i18n/dictionaries/en.json @@ -49,11 +49,12 @@ "loadError": "Couldn't load game info.", "retry": "Retry", "partialFail": "Some data failed to load.", + "noGameData": "No game data found for this app.", "screenshots": "Screenshots", "reviewsUnit": "reviews", "scrollBack": "Back", "scrollForward": "Forward", - "totalReviews": "Total Reviews", + "totalReviews": "Steam", "steamdbRating": "SteamDB Rating", "reviewScores": { "overwhelminglyPositive": "Overwhelmingly Positive", diff --git a/lib/i18n/dictionaries/tr.json b/lib/i18n/dictionaries/tr.json index 0a4ec08..fc14de9 100644 --- a/lib/i18n/dictionaries/tr.json +++ b/lib/i18n/dictionaries/tr.json @@ -49,11 +49,12 @@ "loadError": "Oyun bilgileri yüklenemedi.", "retry": "Tekrar Dene", "partialFail": "Bazı veriler yüklenemedi.", + "noGameData": "Bu uygulama için oyun verisi bulunamadı.", "screenshots": "Görseller", "reviewsUnit": "değerlendirme", "scrollBack": "Geri", "scrollForward": "İleri", - "totalReviews": "Toplam Değerlendirme", + "totalReviews": "Steam", "steamdbRating": "SteamDB Puanı", "reviewScores": { "overwhelminglyPositive": "Son Derece Olumlu", diff --git a/lib/i18n/get-dictionary.test.ts b/lib/i18n/get-dictionary.test.ts new file mode 100644 index 0000000..8ee4a66 --- /dev/null +++ b/lib/i18n/get-dictionary.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { getDictionary } from "@/lib/i18n/get-dictionary"; +import { DEFAULT_LOCALE, matchLocale, type Locale } from "@/lib/i18n/locales"; + +describe("getDictionary", () => { + it("returns the requested locale", () => { + expect(getDictionary("de").discord.gameProfile).toBe("Spielprofil"); + }); + + it("falls back to Turkish for unknown locales", () => { + expect(getDictionary(matchLocale("fr"))).toEqual(getDictionary("tr")); + }); + + it("keeps all locales structurally in sync", () => { + const shape = (locale: Locale): string => JSON.stringify(getDictionary(locale)); + expect(Object.keys(JSON.parse(shape("en"))).sort()).toEqual( + Object.keys(JSON.parse(shape("tr"))).sort(), + ); + expect(Object.keys(JSON.parse(shape("de"))).sort()).toEqual( + Object.keys(JSON.parse(shape("tr"))).sort(), + ); + }); + + it("defaults to Turkish", () => { + expect(DEFAULT_LOCALE).toBe("tr"); + }); +}); diff --git a/lib/i18n/get-dictionary.ts b/lib/i18n/get-dictionary.ts index fb53f37..670d4a4 100644 --- a/lib/i18n/get-dictionary.ts +++ b/lib/i18n/get-dictionary.ts @@ -8,5 +8,5 @@ const dictionaries = { tr, en, de } as const; export type Dictionary = typeof tr; export function getDictionary(locale: Locale): Dictionary { - return dictionaries[locale] ?? dictionaries.tr; + return dictionaries[locale]; } diff --git a/lib/i18n/get-locale.test.ts b/lib/i18n/get-locale.test.ts new file mode 100644 index 0000000..b68a7b6 --- /dev/null +++ b/lib/i18n/get-locale.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/headers", () => ({ + cookies: vi.fn(), +})); + +import { cookies } from "next/headers"; +import { getLocale } from "@/lib/i18n/get-locale"; + +describe("getLocale", () => { + it("returns the cookie locale when valid", async () => { + vi.mocked(cookies).mockResolvedValue({ get: () => ({ value: "de" }) } as never); + await expect(getLocale()).resolves.toBe("de"); + }); + + it("falls back to default without a cookie", async () => { + vi.mocked(cookies).mockResolvedValue({ get: () => undefined } as never); + await expect(getLocale()).resolves.toBe("tr"); + }); + + it("falls back on unknown values", async () => { + vi.mocked(cookies).mockResolvedValue({ get: () => ({ value: "fr" }) } as never); + await expect(getLocale()).resolves.toBe("tr"); + }); +}); diff --git a/lib/i18n/locales.test.ts b/lib/i18n/locales.test.ts new file mode 100644 index 0000000..81d8311 --- /dev/null +++ b/lib/i18n/locales.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { isLocale, matchLocale } from "@/lib/i18n/locales"; + +describe("isLocale", () => { + it("accepts supported locales", () => { + expect(isLocale("tr")).toBe(true); + expect(isLocale("en")).toBe(true); + expect(isLocale("de")).toBe(true); + }); + + it("rejects anything else", () => { + expect(isLocale("fr")).toBe(false); + expect(isLocale("")).toBe(false); + }); +}); + +describe("matchLocale", () => { + it("defaults without a header", () => { + expect(matchLocale(null)).toBe("tr"); + expect(matchLocale("")).toBe("tr"); + }); + + it("matches primary ranges", () => { + expect(matchLocale("en-US,en;q=0.9")).toBe("en"); + expect(matchLocale("de-DE,de;q=0.9,en;q=0.8")).toBe("de"); + }); + + it("respects q-values", () => { + expect(matchLocale("en;q=0.5,tr;q=0.9")).toBe("tr"); + }); + + it("falls back on unknown or wildcard ranges", () => { + expect(matchLocale("fr-FR,fr;q=0.9")).toBe("tr"); + expect(matchLocale("*")).toBe("tr"); + }); +}); diff --git a/lib/i18n/set-locale-cookie.ts b/lib/i18n/set-locale-cookie.ts deleted file mode 100644 index f3fe83c..0000000 --- a/lib/i18n/set-locale-cookie.ts +++ /dev/null @@ -1,16 +0,0 @@ -"use server"; - -import { cookies } from "next/headers"; -import { LOCALE_COOKIE, isLocale, type Locale } from "./locales"; - -export async function setLocaleCookie(locale: Locale): Promise { - if (!isLocale(locale)) { - return; - } - const store = await cookies(); - store.set(LOCALE_COOKIE, locale, { - path: "/", - maxAge: 60 * 60 * 24 * 365, - sameSite: "lax", - }); -} diff --git a/lib/log.test.ts b/lib/log.test.ts new file mode 100644 index 0000000..899bb62 --- /dev/null +++ b/lib/log.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { warnDev } from "@/lib/log"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("warnDev", () => { + it("warns outside production", () => { + vi.stubGlobal("process", { ...process, env: { ...process.env, NODE_ENV: "test" } }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnDev("hello"); + expect(warn).toHaveBeenCalledWith("hello"); + }); + + it("stays silent in production", () => { + vi.stubGlobal("process", { ...process, env: { ...process.env, NODE_ENV: "production" } }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnDev("hello"); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/log.ts b/lib/log.ts new file mode 100644 index 0000000..7ed1828 --- /dev/null +++ b/lib/log.ts @@ -0,0 +1,6 @@ +export function warnDev(message: string): void { + if (process.env.NODE_ENV === "production") { + return; + } + console.warn(message); +} diff --git a/lib/site.ts b/lib/site.ts index f4061c9..6179ab5 100644 --- a/lib/site.ts +++ b/lib/site.ts @@ -7,3 +7,7 @@ export const DISCORD_SERVER_URL = "https://discord.gg/fristy"; export const SPOTIFY_URL = "https://open.spotify.com/user/gayiw5j9i0rtvyra6gfzhpfwf"; export const STEAM_URL = "https://steamcommunity.com/id/fristylikeaillusion"; export const APACHE_LICENSE_URL = "https://www.apache.org/licenses/LICENSE-2.0"; +export const DEV_ORIGINS: ReadonlySet = new Set([ + "http://localhost:3000", + "http://127.0.0.1:3000", +]); diff --git a/package.json b/package.json index 2b7dc86..87a4a59 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build", "start": "next start", "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", "format": "prettier --write .", "format:check": "prettier --check ." }, @@ -17,15 +19,20 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", + "@testing-library/react": "^16.3.3", "@types/node": "^26.5.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", "eslint": "^9.39.5", "eslint-config-next": "16.3.4", "eslint-config-prettier": "^10.1.8", + "jsdom": "^30.0.1", "prettier": "^3.9.6", "tailwindcss": "^4.3.3", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^5.0.0" }, "packageManager": "pnpm@11.2.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c16c6f2..5f0d8d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@types/node': specifier: ^26.5.0 version: 26.5.0 @@ -30,6 +33,9 @@ importers: '@types/react-dom': specifier: ^19.2.7 version: 19.2.7(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.1.1 + version: 6.1.1(vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0)) eslint: specifier: ^9.39.5 version: 9.39.5(jiti@2.7.0) @@ -39,6 +45,9 @@ importers: eslint-config-prettier: specifier: ^10.1.8 version: 10.1.8(eslint@9.39.5(jiti@2.7.0)) + jsdom: + specifier: ^30.0.1 + version: 30.0.1 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -48,6 +57,12 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vite-tsconfig-paths: + specifier: ^6.1.1 + version: 6.1.1(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0)) + vitest: + specifier: ^5.0.0 + version: 5.0.0(@types/node@26.5.0)(jsdom@30.0.1)(vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0)) packages: @@ -55,6 +70,14 @@ packages: resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} engines: {node: '>=10'} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -110,6 +133,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -122,6 +149,46 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -178,6 +245,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -457,6 +533,108 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@oxc-project/types@0.149.0': + resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -555,9 +733,37 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -757,6 +963,36 @@ packages: cpu: [x64] os: [win32] + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.0': + resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.0': + resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -770,13 +1006,24 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -813,6 +1060,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -844,6 +1095,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -879,6 +1133,10 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -903,12 +1161,20 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -938,6 +1204,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -949,6 +1218,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -957,6 +1230,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -971,6 +1247,10 @@ packages: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -991,6 +1271,9 @@ packages: resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -1138,10 +1421,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1190,6 +1480,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1243,6 +1538,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1283,6 +1581,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1374,6 +1676,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -1431,6 +1736,15 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1478,30 +1792,60 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} @@ -1509,6 +1853,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} @@ -1516,6 +1867,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} @@ -1523,6 +1881,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} @@ -1530,22 +1895,45 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1557,16 +1945,30 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.3.1: + resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1662,6 +2064,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1682,6 +2088,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1725,6 +2134,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -1743,6 +2156,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -1755,6 +2171,10 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1771,6 +2191,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.2.8: + resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1786,6 +2211,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1843,6 +2272,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1850,6 +2282,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -1906,6 +2344,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1913,20 +2354,54 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + deprecated: unmaintained + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -1972,6 +2447,10 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} @@ -1984,6 +2463,115 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite-tsconfig-paths@6.1.1: + resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} + peerDependencies: + vite: '*' + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.0: + resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.1: + resolution: {integrity: sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==} + engines: {node: ^22.14.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2005,10 +2593,22 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2029,6 +2629,21 @@ snapshots: '@alloc/quick-lru@5.3.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -2106,6 +2721,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -2129,6 +2746,34 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2201,6 +2846,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2397,6 +3044,55 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.149.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} '@swc/helpers@0.5.23': @@ -2472,11 +3168,41 @@ snapshots: postcss: 8.5.28 tailwindcss: 4.3.3 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -2656,6 +3382,22 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0(@types/node@26.5.0)(jiti@2.7.0) + + '@vitest/mocker@5.0.0(vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.3.1 + optionalDependencies: + vite: 8.3.0(@types/node@26.5.0)(jiti@2.7.0) + + '@vitest/spy@5.0.0': {} + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 @@ -2669,12 +3411,20 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -2744,6 +3494,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -2762,6 +3514,10 @@ snapshots: baseline-browser-mapping@2.11.21: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -2804,6 +3560,8 @@ snapshots: caniuse-lite@1.0.30001810: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -2827,10 +3585,22 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -2857,6 +3627,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -2871,12 +3643,16 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + dequal@2.0.3: {} + detect-libc@2.1.2: {} doctrine@2.1.0: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -2892,6 +3668,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@8.1.0: {} + es-abstract-get@1.0.0: dependencies: es-errors: 1.3.0 @@ -2979,6 +3757,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -3214,8 +3994,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.1: @@ -3262,6 +4048,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.2.0: @@ -3327,6 +4116,8 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globrex@0.1.2: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -3359,6 +4150,12 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + ignore@5.3.2: {} ignore@7.0.9: {} @@ -3453,6 +4250,8 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -3513,6 +4312,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -3552,36 +4377,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -3598,6 +4456,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -3608,16 +4482,26 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.3.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -3719,6 +4603,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@2.2.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3747,6 +4633,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3777,6 +4667,12 @@ snapshots: prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -3794,6 +4690,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react@19.2.8: {} reflect.getprototypeof@1.0.10: @@ -3816,6 +4714,8 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -3831,6 +4731,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -3854,6 +4775,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -3950,10 +4875,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -4027,23 +4958,47 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 + tsconfck@3.1.6(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -4112,6 +5067,8 @@ snapshots: undici-types@8.9.0: {} + undici@8.10.2: {} + unrs-resolver@1.12.2: dependencies: napi-postinstall: 0.3.4 @@ -4149,6 +5106,74 @@ snapshots: dependencies: punycode: 2.3.1 + vite-tsconfig-paths@6.1.1(typescript@6.0.3)(vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@6.0.3) + vite: 8.3.0(@types/node@26.5.0)(jiti@2.7.0) + transitivePeerDependencies: + - supports-color + - typescript + + vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.5.0 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@5.0.0(@types/node@26.5.0)(jsdom@30.0.1)(vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@8.3.0(@types/node@26.5.0)(jiti@2.7.0)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.3.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.0(@types/node@26.5.0)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.5.0 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -4194,8 +5219,17 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/proxy.test.ts b/proxy.test.ts new file mode 100644 index 0000000..b442087 --- /dev/null +++ b/proxy.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; +import { _resetRateLimit, proxy } from "@/proxy"; + +afterEach(() => { + _resetRateLimit(); +}); + +function apiRequest(path: string, init?: { method?: string; origin?: string | null }): NextRequest { + const headers = new Headers(); + if (init?.origin !== null) { + headers.set("origin", init?.origin ?? "https://fristy.net"); + } + return new NextRequest(`https://fristy.net${path}`, { + method: init?.method ?? "GET", + headers, + }); +} + +describe("proxy /api/*", () => { + it("answers OPTIONS preflight with CORS headers", () => { + const response = proxy(apiRequest("/api/game/1", { method: "OPTIONS" })); + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, OPTIONS"); + }); + + it("rejects foreign origins", () => { + const response = proxy(apiRequest("/api/game/1", { origin: "https://evil.test" })); + expect(response.status).toBe(403); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + }); + + it("passes same-origin API requests with CORS headers", () => { + const response = proxy(apiRequest("/api/game/1")); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + }); +}); + +describe("proxy rate limit", () => { + function ipRequest(ip: string): NextRequest { + const request = apiRequest("/api/game/1"); + request.headers.set("cf-connecting-ip", ip); + return request; + } + + it("allows requests under the per-IP limit", () => { + for (let i = 0; i < 30; i += 1) { + expect(proxy(ipRequest("10.0.0.1")).status).not.toBe(429); + } + }); + + it("returns 429 with Retry-After past the limit", async () => { + for (let i = 0; i < 30; i += 1) { + proxy(ipRequest("10.0.0.2")); + } + const limited = proxy(ipRequest("10.0.0.2")); + expect(limited.status).toBe(429); + expect(limited.headers.get("Retry-After")).toBe("60"); + expect(limited.headers.get("Access-Control-Allow-Origin")).toBe("https://fristy.net"); + await expect(limited.json()).resolves.toEqual({ error: "Too many requests" }); + }); + + it("isolates buckets per client IP", () => { + for (let i = 0; i < 30; i += 1) { + proxy(ipRequest("10.0.0.3")); + } + expect(proxy(ipRequest("10.0.0.3")).status).toBe(429); + expect(proxy(ipRequest("10.0.0.4")).status).not.toBe(429); + }); + + it("prefers cf-connecting-ip over x-forwarded-for", () => { + for (let i = 0; i < 30; i += 1) { + const request = apiRequest("/api/game/1"); + request.headers.set("cf-connecting-ip", "10.0.0.5"); + request.headers.set("x-forwarded-for", "10.0.0.6"); + proxy(request); + } + expect(proxy(ipRequest("10.0.0.5")).status).toBe(429); + expect(proxy(ipRequest("10.0.0.6")).status).not.toBe(429); + }); +}); + +describe("proxy locale", () => { + it("sets the locale cookie from accept-language", () => { + const request = apiRequest("/"); + request.headers.set("accept-language", "de-DE,de;q=0.9"); + const response = proxy(request); + expect(response.cookies.get("locale")?.value).toBe("de"); + }); + + it("passes through when the cookie already exists", () => { + const request = apiRequest("/"); + request.cookies.set("locale", "en"); + const response = proxy(request); + expect(response.headers.get("set-cookie")).toBeNull(); + }); +}); diff --git a/proxy.ts b/proxy.ts index 2256ffa..750a91b 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,8 +1,85 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { LOCALE_COOKIE, matchLocale } from "./lib/i18n/locales"; +import { corsHeaders, forbidden, isAllowedOrigin } from "@/lib/api-route"; +import { LOCALE_COOKIE, matchLocale } from "@/lib/i18n/locales"; -export function proxy(request: NextRequest) { +function withCorsHeaders(response: NextResponse, origin: string | null): NextResponse { + const headers = corsHeaders(origin); + for (const [key, value] of Object.entries(headers)) { + response.headers.set(key, value); + } + return response; +} + +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX_REQUESTS = 30; + +const rateLimitHits = new Map(); + +export function _resetRateLimit(): void { + rateLimitHits.clear(); +} + +export function clientIp(request: NextRequest): string { + const cloudflareIp = request.headers.get("cf-connecting-ip")?.trim(); + if (cloudflareIp) { + return cloudflareIp; + } + const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); + if (forwardedFor) { + return forwardedFor; + } + return "direct"; +} + +function isRateLimited(ip: string, now: number = Date.now()): boolean { + const cutoff = now - RATE_LIMIT_WINDOW_MS; + const hits = (rateLimitHits.get(ip) ?? []).filter((timestamp) => timestamp > cutoff); + if (hits.length >= RATE_LIMIT_MAX_REQUESTS) { + rateLimitHits.set(ip, hits); + return true; + } + hits.push(now); + rateLimitHits.set(ip, hits); + return false; +} + +function rateLimited(origin: string | null): NextResponse { + return NextResponse.json( + { error: "Too many requests" }, + { + status: 429, + headers: { + "Cache-Control": "no-store", + "Retry-After": String(Math.ceil(RATE_LIMIT_WINDOW_MS / 1000)), + ...corsHeaders(origin), + }, + }, + ); +} + +function handleApi(request: NextRequest): NextResponse { + const origin = request.headers.get("origin"); + if (isRateLimited(clientIp(request))) { + return rateLimited(origin); + } + if (!isAllowedOrigin(request)) { + return forbidden(origin); + } + if (request.method === "OPTIONS") { + const response = new NextResponse(null, { status: 204 }); + withCorsHeaders(response, origin); + response.headers.set("Access-Control-Allow-Methods", "GET, OPTIONS"); + response.headers.set("Access-Control-Max-Age", "86400"); + return response; + } + return withCorsHeaders(NextResponse.next(), origin); +} + +export function proxy(request: NextRequest): NextResponse { + if (request.nextUrl.pathname.startsWith("/api/")) { + return handleApi(request); + } if (request.cookies.has(LOCALE_COOKIE)) { return NextResponse.next(); } diff --git a/scorecard.png b/scorecard.png new file mode 100644 index 0000000000000000000000000000000000000000..6b6541222cf8e00cdb61af4a9cee620e81de9018 GIT binary patch literal 89521 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�~tAajUR%#sK^RWtajVF^gVOp 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 ) : null} - {profile ? : null} + {profile ? ( + + ) : null} {profile?.itad && (bestPrice || historyLow) ? ( { it("renders nothing without review sources", () => { const dict = getDictionary("en"); const { container } = render( - , + , ); expect(container.textContent).toBe(""); }); diff --git a/lib/api-route.ts b/lib/api-route.ts index b19b561..6f7c403 100644 --- a/lib/api-route.ts +++ b/lib/api-route.ts @@ -22,10 +22,7 @@ export function isAllowedOrigin(request: Request): boolean { } function resolveAllowedOrigin(origin: string | null): string { - if ( - origin !== null && - (origin === ALLOWED_ORIGIN || (isDev() && DEV_ORIGINS.has(origin))) - ) { + if (origin !== null && (origin === ALLOWED_ORIGIN || (isDev() && DEV_ORIGINS.has(origin)))) { return origin; } return ALLOWED_ORIGIN; @@ -79,11 +76,7 @@ export function jsonErrorWithCors( }, }); } -export function jsonWithCors( - data: unknown, - sMaxage = 3600, - origin?: string | null, -): NextResponse { +export function jsonWithCors(data: unknown, sMaxage = 3600, origin?: string | null): NextResponse { return NextResponse.json(data, { headers: { "Cache-Control": `public, s-maxage=${sMaxage}, stale-while-revalidate=86400`, diff --git a/lib/game/game-format.ts b/lib/game/game-format.ts index 0567c06..e08e264 100644 --- a/lib/game/game-format.ts +++ b/lib/game/game-format.ts @@ -69,7 +69,11 @@ export function formatReleaseDate(value: string, localeTag: string): string { return value; } -export function formatCount(value: number | null | undefined, localeTag: string, unit: string): string { +export function formatCount( + value: number | null | undefined, + localeTag: string, + unit: string, +): string { if (value == null) { return "—"; } diff --git a/lib/game/game-profile.test.ts b/lib/game/game-profile.test.ts index 68de67d..d516b4e 100644 --- a/lib/game/game-profile.test.ts +++ b/lib/game/game-profile.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { buildLinks, mergePeople, mergePlatforms, selectScreenshots } from "@/lib/game/game-profile"; +import { + buildLinks, + mergePeople, + mergePlatforms, + selectScreenshots, +} from "@/lib/game/game-profile"; import type { IgdbGame } from "@/lib/game/game-profile-types"; describe("mergePeople", () => { diff --git a/lib/game/game-profile.ts b/lib/game/game-profile.ts index 4c25e52..458f40d 100644 --- a/lib/game/game-profile.ts +++ b/lib/game/game-profile.ts @@ -186,7 +186,9 @@ export function toPricePoint(p: ItadOverviewPrice["current"]): GamePricePoint | export function mapItadBlock( itadInfo: ItadGameInfo | null, - priceEntry: { current?: ItadOverviewPrice["current"]; lowest?: ItadOverviewPrice["current"] } | undefined, + priceEntry: + | { current?: ItadOverviewPrice["current"]; lowest?: ItadOverviewPrice["current"] } + | undefined, ): NonNullable | null { if (!process.env.ITAD_API_KEY || (!itadInfo && !priceEntry)) { return null; @@ -197,10 +199,7 @@ export function mapItadBlock( pageUrl: itadInfo?.urls?.game ?? null, playerCount: itadInfo?.players?.recent ?? null, reviews: (itadInfo?.reviews ?? []) - .filter( - (r): r is ItadReview & { source: string; url: string } => - !!r.source && !!r.url, - ) + .filter((r): r is ItadReview & { source: string; url: string } => !!r.source && !!r.url) .map((r) => ({ score: r.score ?? null, source: r.source, diff --git a/lib/game/lanyard.test.ts b/lib/game/lanyard.test.ts index 2529d75..528f49c 100644 --- a/lib/game/lanyard.test.ts +++ b/lib/game/lanyard.test.ts @@ -25,9 +25,9 @@ describe("activityImageUrl", () => { }); it("resolves Spotify keys", () => { - expect(activityImageUrl(activity({ assets: { large_image: "spotify:img" } }), "large")).toBe( - "https://i.scdn.co/image/img", - ); + expect( + activityImageUrl(activity({ assets: { large_image: "spotify:img" } }), "large"), + ).toBe("https://i.scdn.co/image/img"); }); it("resolves app assets with application id", () => {
M~uY>wCb!EYcL*S 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 = {}): GameProfile { + return { + applicationId: "1", + gameId: "1", + gameUrl: "https://discord.com/games/1", + name: "Test Game", + genres: ["RPG"], + description: "A test game", + developers: ["Dev"], + publishers: ["Pub"], + releaseDate: "2024-01-15", + platforms: ["Windows"], + links: [], + coverUrl: null, + artworkUrl: null, + screenshots: [], + steamId: "100", + steamUrl: "https://store.steampowered.com/app/100/", + igdbUrl: null, + steamReviews: null, + itad: null, + sources: { igdb: false, steam: true, itad: false }, + ...overrides, + }; +} + +export function lanyardFixture(overrides: Partial = {}): LanyardData { + return { + discord_user: { + id: "1", + username: "testuser", + global_name: "Test User", + display_name: "Test User", + discriminator: "0", + avatar: null, + bot: false, + }, + activities: [], + discord_status: "online", + active_on_discord_desktop: true, + active_on_discord_mobile: false, + active_on_discord_web: false, + listening_to_spotify: false, + spotify: null, + ...overrides, + }; +} + +export function repoFixture(): GiteaRepo { + return { + id: 1, + name: "demo", + fullName: "fristy/demo", + description: "A demo repo", + language: "TypeScript", + htmlUrl: "https://git.fristy.net/fristy/demo", + website: null, + stars: 3, + forks: 1, + updatedAt: "2026-01-01T00:00:00Z", + }; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..064bb35 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,12 @@ +import tsconfigPaths from "vite-tsconfig-paths"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths(), react()], + test: { + environment: "jsdom", + include: ["**/*.test.ts", "**/*.test.tsx"], + exclude: ["node_modules", ".next", "out", "coverage"], + }, +}); From ed149ded52f7149539f581bea9fcde6358920814 Mon Sep 17 00:00:00 2001 From: fristyxd Date: Sat, 12 Sep 2026 00:47:38 +0300 Subject: [PATCH 2/2] fix: prettier formatting and downgrade upload-artifact to v3 for Gitea --- .gitea/workflows/ci.yaml | 2 +- app/api/discord-app/[id]/route.ts | 4 +++- components/game-profile-modal.tsx | 4 +++- components/game-profile-sections.test.tsx | 6 +++++- lib/api-route.ts | 11 ++--------- lib/game/game-format.ts | 6 +++++- lib/game/game-profile.test.ts | 7 ++++++- lib/game/game-profile.ts | 9 ++++----- lib/game/lanyard.test.ts | 6 +++--- 9 files changed, 32 insertions(+), 23 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 22e71f6..108caaf 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -54,7 +54,7 @@ jobs: run: | pipx run --spec desloppify desloppify scan --profile ci --path . --no-badge - name: Upload scorecard - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v3 with: name: desloppify-scorecard path: scorecard.png diff --git a/app/api/discord-app/[id]/route.ts b/app/api/discord-app/[id]/route.ts index dc7a97f..1cefdb7 100644 --- a/app/api/discord-app/[id]/route.ts +++ b/app/api/discord-app/[id]/route.ts @@ -22,7 +22,9 @@ export async function GET( try { info = await getDiscordAppInfo(id); } catch (error) { - warnDev(`discord-app upstream failed: ${id} -> ${error instanceof Error ? error.message : error}`); + warnDev( + `discord-app upstream failed: ${id} -> ${error instanceof Error ? error.message : error}`, + ); return jsonErrorWithCors({ error: "Upstream unavailable" }, 502, 60, origin); } if (!info) { diff --git a/components/game-profile-modal.tsx b/components/game-profile-modal.tsx index 1615458..c011dda 100644 --- a/components/game-profile-modal.tsx +++ b/components/game-profile-modal.tsx @@ -285,7 +285,9 @@ export function GameProfileModal({