From 50832556545dc298e4fa76d23a7ef6ddefbc3d88 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 25 May 2026 21:24:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20rewrite=20sitemap=20with=20generateSite?= =?UTF-8?q?maps=20pattern=20=E2=80=94=204=20isolated=20child=20sitemaps=20?= =?UTF-8?q?(Task=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/__tests__/sitemap.test.ts | 360 +++++++++++++++++++++++----------- app/sitemap.ts | 217 +++++++++++++++----- 2 files changed, 415 insertions(+), 162 deletions(-) diff --git a/app/__tests__/sitemap.test.ts b/app/__tests__/sitemap.test.ts index 1ed652a..3a163ad 100644 --- a/app/__tests__/sitemap.test.ts +++ b/app/__tests__/sitemap.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest" // ── Mock data ────────────────────────────────────────────────────────── -let mockGameRows: Array<{ id: string; updatedAt: Date | null; capsuleImage: string | null }> = [] -let mockDeviceRows: Array<{ slug: string; createdAt: Date | null }> = [] +let mockData: unknown[] = [] // ── Chainable query builder mock ────────────────────────────────────── function createChainableQuery(resolveWith: unknown[]) { @@ -18,16 +17,10 @@ function createChainableQuery(resolveWith: unknown[]) { return chain } -let devicesCallCount = 0 - vi.mock("@/lib/db/index", () => ({ db: { select: vi.fn(() => ({ - from: vi.fn(() => { - devicesCallCount++ - const data = devicesCallCount % 2 === 1 ? mockGameRows : mockDeviceRows - return createChainableQuery(data) - }), + from: vi.fn(() => createChainableQuery(mockData)), })), }, })) @@ -48,138 +41,285 @@ vi.mock("drizzle-orm", () => ({ isNull: vi.fn((col: unknown) => col), })) -// Mock the updates module (markdown-based changelogs) +const mockGetAllUpdates = vi.fn(() => []) vi.mock("@/lib/updates", () => ({ - getAllUpdates: vi.fn(() => []), + getAllUpdates: mockGetAllUpdates, })) describe("Sitemap Generator (app/sitemap.ts)", () => { beforeEach(() => { vi.clearAllMocks() - mockGameRows = [] - mockDeviceRows = [] - devicesCallCount = 0 + mockData = [] + mockGetAllUpdates.mockReturnValue([]) }) + // ── Configuration ────────────────────────────────────────────────── + it("has ISR revalidation configured", async () => { const mod = await import("@/app/sitemap") expect(mod.revalidate).toBe(3600) }) - it("returns static pages even with empty DB results", async () => { - const mod = await import("@/app/sitemap") - const result = await mod.default() + // ── generateSitemaps ─────────────────────────────────────────────── - expect(Array.isArray(result)).toBe(true) - // 7 static pages (now includes /compare and /search) - expect(result.length).toBeGreaterThanOrEqual(7) - // First entry should be the homepage with priority 1 - expect(result[0].url).toContain("deckyvault.xyz") - expect(result[0].priority).toBe(1) + describe("generateSitemaps", () => { + it("returns at least 4 child sitemap IDs", async () => { + mockData = [{ count: 500 }] + const mod = await import("@/app/sitemap") + const ids = await mod.generateSitemaps() + + const idValues = ids.map((x: { id: string }) => x.id) + expect(idValues).toContain("static") + expect(idValues).toContain("devices") + expect(idValues).toContain("updates") + expect(idValues).toContain("games") + }) + + it("returns unpaginated games when count <= 5000", async () => { + mockData = [{ count: 5000 }] + const mod = await import("@/app/sitemap") + const ids = await mod.generateSitemaps() + + const idValues = ids.map((x: { id: string }) => x.id) + expect(idValues).toContain("games") + expect(idValues).not.toContain("games-0") + expect(idValues).not.toContain("games-1") + }) + + it("paginates games when count > 5000", async () => { + mockData = [{ count: 7500 }] + const mod = await import("@/app/sitemap") + const ids = await mod.generateSitemaps() + + const idValues = ids.map((x: { id: string }) => x.id) + expect(idValues).toContain("games-0") + expect(idValues).toContain("games-1") + expect(idValues).not.toContain("games") + }) + + it("falls back to unpaginated games when count query fails", async () => { + // mockData empty — count will be 0 from empty array, simulating a failed query + mockData = [] + const mod = await import("@/app/sitemap") + const ids = await mod.generateSitemaps() + + const idValues = ids.map((x: { id: string }) => x.id) + expect(idValues).toContain("games") + }) }) - it("includes compare and search in static pages", async () => { - const mod = await import("@/app/sitemap") - const result = await mod.default() + // ── sitemap({ id: 'static' }) ────────────────────────────────────── - const urls = result.map((e: { url: string }) => e.url) - expect(urls).toContain("https://deckyvault.xyz/compare") - expect(urls).toContain("https://deckyvault.xyz/search") + describe("sitemap({ id: 'static' })", () => { + it("returns 7 static page entries", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(7) + }) + + it("first entry is homepage with priority 1.0", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) + + expect(result[0].url).toContain("deckyvault.xyz") + expect(result[0].priority).toBe(1.0) + }) + + it("includes /games and /compare", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) + + const urls = result.map((e: { url: string }) => e.url) + expect(urls).toContain("https://deckyvault.xyz/games") + expect(urls).toContain("https://deckyvault.xyz/compare") + }) + + it("includes lastModified on all entries", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) + + for (const entry of result) { + expect(entry.lastModified).toBeDefined() + expect(entry.lastModified).toBeInstanceOf(Date) + } + }) + + it("uses production URL even when env is localhost", async () => { + process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000" + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) + + expect(result[0].url).toContain("deckyvault.xyz") + expect(result[0].url).not.toContain("localhost") + + delete process.env.NEXT_PUBLIC_SITE_URL + }) + + it("uses custom NEXT_PUBLIC_SITE_URL for staging", async () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz" + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) + + expect(result[0].url).toContain("staging.deckyvault.xyz") + + delete process.env.NEXT_PUBLIC_SITE_URL + }) }) - it("includes game entries from DB", async () => { - mockGameRows = [ - { id: "123456", updatedAt: new Date("2025-01-01"), capsuleImage: "https://cdn.example.com/img.jpg" }, - ] - mockDeviceRows = [] + // ── sitemap({ id: 'games' }) ─────────────────────────────────────── - const mod = await import("@/app/sitemap") - const result = await mod.default() + describe("sitemap({ id: 'games' })", () => { + it("returns game entries from the database", async () => { + mockData = [ + { id: "abc123", updatedAt: new Date("2025-06-01"), capsuleImage: "https://cdn.example.com/img.jpg" }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("games") }) - const gameEntry = result.find((e: { url: string }) => e.url.includes("/game/123456")) - expect(gameEntry).toBeDefined() - expect(gameEntry!.url).toContain("deckyvault.xyz/game/123456") - expect(gameEntry!.priority).toBe(0.8) + expect(result).toHaveLength(1) + expect(result[0].url).toContain("deckyvault.xyz/game/abc123") + expect(result[0].priority).toBe(0.8) + }) + + it("includes image entries for valid capsule URLs", async () => { + mockData = [ + { id: "img123", updatedAt: null, capsuleImage: "https://cdn.example.com/capsule.jpg" }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("games") }) + + expect(result[0].images).toEqual(["https://cdn.example.com/capsule.jpg"]) + }) + + it("omits images for null capsuleImage", async () => { + mockData = [ + { id: "noimg", updatedAt: null, capsuleImage: null }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("games") }) + + expect(result[0].images).toBeUndefined() + }) + + it("includes lastModified from updatedAt", async () => { + const date = new Date("2025-01-15T10:00:00Z") + mockData = [ + { id: "date123", updatedAt: date, capsuleImage: null }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("games") }) + + expect(result[0].lastModified).toBe(date) + }) + + it("returns empty array when DB query fails", async () => { + // mockData is empty, querySafe will return undefined (no data) + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("games") }) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(0) + }) + + it("applies changeFrequency weekly and priority 0.8", async () => { + mockData = [ + { id: "freq", updatedAt: null, capsuleImage: null }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("games") }) + + expect(result[0].changeFrequency).toBe("weekly") + expect(result[0].priority).toBe(0.8) + }) }) - it("includes image entries for games with capsule images", async () => { - mockGameRows = [ - { id: "abc123", updatedAt: null, capsuleImage: "https://cdn.example.com/capsule.jpg" }, - ] - mockDeviceRows = [] + // ── sitemap({ id: 'devices' }) ───────────────────────────────────── - const mod = await import("@/app/sitemap") - const result = await mod.default() + describe("sitemap({ id: 'devices' })", () => { + it("returns device entries from the database", async () => { + mockData = [ + { slug: "steam-deck-oled", createdAt: new Date("2025-03-01") }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("devices") }) - const gameEntry = result.find((e: { url: string }) => e.url.includes("/game/abc123")) - expect(gameEntry).toBeDefined() - expect(gameEntry!.images).toEqual(["https://cdn.example.com/capsule.jpg"]) + expect(result).toHaveLength(1) + expect(result[0].url).toContain("deckyvault.xyz/devices/steam-deck-oled") + }) + + it("sets priority 0.6 and changeFrequency monthly", async () => { + mockData = [ + { slug: "device-1", createdAt: null }, + ] + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("devices") }) + + expect(result[0].priority).toBe(0.6) + expect(result[0].changeFrequency).toBe("monthly") + }) + + it("returns empty array when DB query returns no rows", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("devices") }) + + expect(result).toHaveLength(0) + }) }) - it("handles game with null capsuleImage gracefully", async () => { - mockGameRows = [ - { id: "noimg", updatedAt: null, capsuleImage: null }, - ] - mockDeviceRows = [] + // ── sitemap({ id: 'updates' }) ───────────────────────────────────── - const mod = await import("@/app/sitemap") - const result = await mod.default() + describe("sitemap({ id: 'updates' })", () => { + it("returns update entries from markdown files", async () => { + mockGetAllUpdates.mockReturnValue([ + { slug: "2026-01-01", title: "Release", date: "2026-01-01", version: "1.0.0", summary: "First" }, + ]) + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("updates") }) - const gameEntry = result.find((e: { url: string }) => e.url.includes("/game/noimg")) - expect(gameEntry).toBeDefined() - expect(gameEntry!.images).toBeUndefined() + expect(result).toHaveLength(1) + expect(result[0].url).toContain("deckyvault.xyz/updates/2026-01-01") + }) + + it("sets priority 0.5 and changeFrequency monthly", async () => { + mockGetAllUpdates.mockReturnValue([ + { slug: "upd", title: "T", date: "2026-01-01", version: "1.0.0", summary: "S" }, + ]) + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("updates") }) + + expect(result[0].priority).toBe(0.5) + expect(result[0].changeFrequency).toBe("monthly") + }) + + it("returns empty array when getAllUpdates throws", async () => { + mockGetAllUpdates.mockImplementation(() => { + throw new Error("Failed to read updates directory") + }) + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("updates") }) + + expect(Array.isArray(result)).toBe(true) + expect(result).toHaveLength(0) + }) }) - it("includes device entries from DB", async () => { - mockGameRows = [] - mockDeviceRows = [ - { slug: "steam-deck-oled", createdAt: new Date("2025-03-01") }, - ] + // ── Isolation ────────────────────────────────────────────────────── - const mod = await import("@/app/sitemap") - const result = await mod.default() + describe("child sitemap isolation", () => { + it("static sitemap works even when DB is empty", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("static") }) - const deviceEntry = result.find((e: { url: string }) => e.url.includes("/devices/steam-deck-oled")) - expect(deviceEntry).toBeDefined() - expect(deviceEntry!.priority).toBe(0.6) + expect(result).toHaveLength(7) + }) + + it("unknown id returns empty array", async () => { + const mod = await import("@/app/sitemap") + const result = await mod.default({ id: Promise.resolve("nonexistent") }) + + expect(result).toEqual([]) + }) }) - - it("includes entries from all data sources combined", async () => { - mockGameRows = [ - { id: "game1", updatedAt: null, capsuleImage: null }, - { id: "game2", updatedAt: null, capsuleImage: null }, - ] - mockDeviceRows = [ - { slug: "device-1", createdAt: null }, - ] - - const mod = await import("@/app/sitemap") - const result = await mod.default() - - // 7 static + 2 games + 1 device = 10 - expect(result.length).toBeGreaterThanOrEqual(10) - }) - - it("always uses production URL when env is localhost", async () => { - process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000" - - const mod = await import("@/app/sitemap") - const result = await mod.default() - - expect(result[0].url).toContain("deckyvault.xyz") - expect(result[0].url).not.toContain("localhost") - - delete process.env.NEXT_PUBLIC_SITE_URL - }) - - it("uses custom NEXT_PUBLIC_SITE_URL when set to non-localhost", async () => { - process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz" - - const mod = await import("@/app/sitemap") - const result = await mod.default() - - expect(result[0].url).toContain("staging.deckyvault.xyz") - - delete process.env.NEXT_PUBLIC_SITE_URL - }) -}) +}) \ No newline at end of file diff --git a/app/sitemap.ts b/app/sitemap.ts index 83d1905..94fc886 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -14,26 +14,112 @@ import { /** Revalidate sitemap every hour via ISR */ export const revalidate = 3600 -/** Maximum entries per sitemap (Google's limit is 50k; we stay well under) */ +/** Maximum entries per individual child sitemap */ const MAX_ENTRIES = 45_000 -// ─── Sitemap builder (called by Next.js on every request + ISR) ──────── +/** Threshold for game pagination — split into per-page child sitemaps */ +const GAMES_PER_SITEMAP = 5_000 -export default async function sitemap(): Promise { - const baseUrl = getBaseUrl() - const entries: MetadataRoute.Sitemap = [] +// ─── Sitemap Index Generator ──────────────────────────────────────── - // ── 1. Static pages ──────────────────────────────────────────────── - for (const page of STATIC_PAGES) { - entries.push({ - url: page.urlPath ? `${baseUrl}${page.urlPath}` : baseUrl, - changeFrequency: page.changeFrequency, - priority: page.priority, - }) +/** + * Returns the list of child sitemap IDs. Next.js auto-generates + * the sitemap index at /sitemap.xml from this. + */ +export async function generateSitemaps(): Promise<{ id: string }[]> { + const ids: { id: string }[] = [ + { id: "static" }, + { id: "devices" }, + { id: "updates" }, + ] + + // Determine if games need pagination + try { + const countResult = await querySafe("game-count", () => + db + .select({ count: games.id }) + .from(games) + .where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))), + ) + const count = countResult?.[0]?.count ?? 0 + if (count > GAMES_PER_SITEMAP) { + const pages = Math.ceil(count / GAMES_PER_SITEMAP) + for (let i = 0; i < pages; i++) { + ids.push({ id: `games-${i}` }) + } + } else { + ids.push({ id: "games" }) + } + } catch { + // Fall back to single unpaginated games sitemap + ids.push({ id: "games" }) } - // ── 2. Game detail pages (from database) ─────────────────────────── - const gameRows = await querySafe("games", () => + return ids +} + +// ─── Child Sitemap Generator ──────────────────────────────────────── + +export default async function sitemap(props: { + id: Promise +}): Promise { + const id = await props.id + + if (id === "static") { + return generateStaticSitemap() + } + + if (id === "games" || id.startsWith("games-")) { + return generateGamesSitemap(id) + } + + if (id === "devices") { + return generateDevicesSitemap() + } + + if (id === "updates") { + return generateUpdatesSitemap() + } + + // Unknown sitemap ID — return empty but valid + console.warn(`[Sitemap] Unknown child sitemap ID: "${id}"`) + return [] +} + +// ─── Individual Generators ────────────────────────────────────────── + +/** + * Static pages sitemap — no DB dependency. + * Returns the core browse/utility pages with a fixed lastModified. + */ +async function generateStaticSitemap(): Promise { + const baseUrl = getBaseUrl() + // Use a constant "build date" — updated with each deploy + const buildDate = new Date() + + return STATIC_PAGES.map((page) => ({ + url: page.urlPath ? `${baseUrl}${page.urlPath}` : baseUrl, + lastModified: buildDate, + changeFrequency: page.changeFrequency, + priority: page.priority, + })) +} + +/** + * Games child sitemap — DB-backed with ISR caching. + * Supports pagination: 'games' (unpaginated) or 'games-0', 'games-1', etc. + */ +async function generateGamesSitemap( + id: string, +): Promise { + const baseUrl = getBaseUrl() + + // Parse pagination: 'games-0' → page 0, 'games' (no suffix) → page 0 + const pageMatch = id.match(/^games-(\d+)$/) + const page = pageMatch ? parseInt(pageMatch[1], 10) : 0 + const offset = page * GAMES_PER_SITEMAP + + const rows = await querySafe("games", () => db .select({ id: games.id, @@ -44,21 +130,33 @@ export default async function sitemap(): Promise { .where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))), ) - if (gameRows) { - for (const row of gameRows) { - if (entries.length >= MAX_ENTRIES) break - entries.push({ - url: `${baseUrl}/game/${row.id}`, - lastModified: toDate(row.updatedAt), - changeFrequency: "weekly", - priority: 0.8, - ...imageEntry(row.capsuleImage), - }) - } + if (!rows) return [] + + const entries: MetadataRoute.Sitemap = [] + + for (const row of rows) { + if (entries.length >= MAX_ENTRIES) break + entries.push({ + url: `${baseUrl}/game/${row.id}`, + lastModified: toDate(row.updatedAt), + changeFrequency: "weekly" as const, + priority: 0.8, + ...imageEntry(row.capsuleImage), + }) } - // ── 3. Device detail pages (from database) ───────────────────────── - const deviceRows = await querySafe("hardware", () => + // Apply pagination slice + const sliced = entries.slice(offset, offset + GAMES_PER_SITEMAP) + return sliced +} + +/** + * Devices child sitemap — DB-backed with ISR caching. + */ +async function generateDevicesSitemap(): Promise { + const baseUrl = getBaseUrl() + + const rows = await querySafe("hardware", () => db .select({ slug: hardware.slug, @@ -67,33 +165,48 @@ export default async function sitemap(): Promise { .from(hardware), ) - if (deviceRows) { - for (const row of deviceRows) { - if (entries.length >= MAX_ENTRIES) break - entries.push({ - url: `${baseUrl}/devices/${row.slug}`, - lastModified: toDate(row.createdAt), - changeFrequency: "monthly", - priority: 0.6, - }) - } - } + if (!rows) return [] - // ── 4. Update / changelog detail pages (from markdown files) ─────── - try { - const updates = getAllUpdates() - for (const update of updates) { - if (entries.length >= MAX_ENTRIES) break - entries.push({ - url: `${baseUrl}/updates/${update.slug}`, - lastModified: toDate(update.date), - changeFrequency: "monthly", - priority: 0.5, - }) - } - } catch (err) { - console.error("[Sitemap] Failed to load updates:", err) + const entries: MetadataRoute.Sitemap = [] + + for (const row of rows) { + if (entries.length >= MAX_ENTRIES) break + entries.push({ + url: `${baseUrl}/devices/${row.slug}`, + lastModified: toDate(row.createdAt), + changeFrequency: "monthly" as const, + priority: 0.6, + }) } return entries } + +/** + * Updates child sitemap — filesystem-backed with ISR caching. + */ +async function generateUpdatesSitemap(): Promise { + const baseUrl = getBaseUrl() + + let updates: ReturnType + try { + updates = getAllUpdates() + } catch (err) { + console.error("[Sitemap] Failed to load updates:", err) + return [] + } + + const entries: MetadataRoute.Sitemap = [] + + for (const update of updates) { + if (entries.length >= MAX_ENTRIES) break + entries.push({ + url: `${baseUrl}/updates/${update.slug}`, + lastModified: toDate(update.date), + changeFrequency: "monthly" as const, + priority: 0.5, + }) + } + + return entries +} \ No newline at end of file