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 0000000..6b65412 Binary files /dev/null and b/scorecard.png differ diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 0000000..9b90c9f --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,66 @@ +import type { GiteaRepo } from "@/lib/gitea"; +import type { GameProfile } from "@/lib/game/game-profile-types"; +import type { LanyardData } from "@/lib/game/lanyard"; + +export function profileFixture(overrides: Partial = {}): 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"], + }, +});