feat: rewrite sitemap with generateSitemaps pattern — 4 isolated child sitemaps (Task 2)

This commit is contained in:
2026-05-25 21:24:11 +08:00
parent 1ed90bb222
commit 5083255654
2 changed files with 415 additions and 162 deletions
+239 -99
View File
@@ -1,8 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest" import { describe, it, expect, vi, beforeEach } from "vitest"
// ── Mock data ────────────────────────────────────────────────────────── // ── Mock data ──────────────────────────────────────────────────────────
let mockGameRows: Array<{ id: string; updatedAt: Date | null; capsuleImage: string | null }> = [] let mockData: unknown[] = []
let mockDeviceRows: Array<{ slug: string; createdAt: Date | null }> = []
// ── Chainable query builder mock ────────────────────────────────────── // ── Chainable query builder mock ──────────────────────────────────────
function createChainableQuery(resolveWith: unknown[]) { function createChainableQuery(resolveWith: unknown[]) {
@@ -18,16 +17,10 @@ function createChainableQuery(resolveWith: unknown[]) {
return chain return chain
} }
let devicesCallCount = 0
vi.mock("@/lib/db/index", () => ({ vi.mock("@/lib/db/index", () => ({
db: { db: {
select: vi.fn(() => ({ select: vi.fn(() => ({
from: vi.fn(() => { from: vi.fn(() => createChainableQuery(mockData)),
devicesCallCount++
const data = devicesCallCount % 2 === 1 ? mockGameRows : mockDeviceRows
return createChainableQuery(data)
}),
})), })),
}, },
})) }))
@@ -48,123 +41,115 @@ vi.mock("drizzle-orm", () => ({
isNull: vi.fn((col: unknown) => col), isNull: vi.fn((col: unknown) => col),
})) }))
// Mock the updates module (markdown-based changelogs) const mockGetAllUpdates = vi.fn(() => [])
vi.mock("@/lib/updates", () => ({ vi.mock("@/lib/updates", () => ({
getAllUpdates: vi.fn(() => []), getAllUpdates: mockGetAllUpdates,
})) }))
describe("Sitemap Generator (app/sitemap.ts)", () => { describe("Sitemap Generator (app/sitemap.ts)", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockGameRows = [] mockData = []
mockDeviceRows = [] mockGetAllUpdates.mockReturnValue([])
devicesCallCount = 0
}) })
// ── Configuration ──────────────────────────────────────────────────
it("has ISR revalidation configured", async () => { it("has ISR revalidation configured", async () => {
const mod = await import("@/app/sitemap") const mod = await import("@/app/sitemap")
expect(mod.revalidate).toBe(3600) expect(mod.revalidate).toBe(3600)
}) })
it("returns static pages even with empty DB results", async () => { // ── generateSitemaps ───────────────────────────────────────────────
describe("generateSitemaps", () => {
it("returns at least 4 child sitemap IDs", async () => {
mockData = [{ count: 500 }]
const mod = await import("@/app/sitemap") const mod = await import("@/app/sitemap")
const result = await mod.default() 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")
})
})
// ── sitemap({ id: 'static' }) ──────────────────────────────────────
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(Array.isArray(result)).toBe(true)
// 7 static pages (now includes /compare and /search) expect(result).toHaveLength(7)
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)
}) })
it("includes compare and search in static pages", async () => { it("first entry is homepage with priority 1.0", async () => {
const mod = await import("@/app/sitemap") const mod = await import("@/app/sitemap")
const result = await mod.default() 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) const urls = result.map((e: { url: string }) => e.url)
expect(urls).toContain("https://deckyvault.xyz/games")
expect(urls).toContain("https://deckyvault.xyz/compare") expect(urls).toContain("https://deckyvault.xyz/compare")
expect(urls).toContain("https://deckyvault.xyz/search")
}) })
it("includes game entries from DB", async () => { it("includes lastModified on all entries", async () => {
mockGameRows = [
{ id: "123456", updatedAt: new Date("2025-01-01"), capsuleImage: "https://cdn.example.com/img.jpg" },
]
mockDeviceRows = []
const mod = await import("@/app/sitemap") const mod = await import("@/app/sitemap")
const result = await mod.default() const result = await mod.default({ id: Promise.resolve("static") })
const gameEntry = result.find((e: { url: string }) => e.url.includes("/game/123456")) for (const entry of result) {
expect(gameEntry).toBeDefined() expect(entry.lastModified).toBeDefined()
expect(gameEntry!.url).toContain("deckyvault.xyz/game/123456") expect(entry.lastModified).toBeInstanceOf(Date)
expect(gameEntry!.priority).toBe(0.8) }
}) })
it("includes image entries for games with capsule images", async () => { it("uses production URL even when env is localhost", async () => {
mockGameRows = [
{ id: "abc123", updatedAt: null, capsuleImage: "https://cdn.example.com/capsule.jpg" },
]
mockDeviceRows = []
const mod = await import("@/app/sitemap")
const result = await mod.default()
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"])
})
it("handles game with null capsuleImage gracefully", async () => {
mockGameRows = [
{ id: "noimg", updatedAt: null, capsuleImage: null },
]
mockDeviceRows = []
const mod = await import("@/app/sitemap")
const result = await mod.default()
const gameEntry = result.find((e: { url: string }) => e.url.includes("/game/noimg"))
expect(gameEntry).toBeDefined()
expect(gameEntry!.images).toBeUndefined()
})
it("includes device entries from DB", async () => {
mockGameRows = []
mockDeviceRows = [
{ slug: "steam-deck-oled", createdAt: new Date("2025-03-01") },
]
const mod = await import("@/app/sitemap")
const result = await mod.default()
const deviceEntry = result.find((e: { url: string }) => e.url.includes("/devices/steam-deck-oled"))
expect(deviceEntry).toBeDefined()
expect(deviceEntry!.priority).toBe(0.6)
})
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" process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000"
const mod = await import("@/app/sitemap") const mod = await import("@/app/sitemap")
const result = await mod.default() const result = await mod.default({ id: Promise.resolve("static") })
expect(result[0].url).toContain("deckyvault.xyz") expect(result[0].url).toContain("deckyvault.xyz")
expect(result[0].url).not.toContain("localhost") expect(result[0].url).not.toContain("localhost")
@@ -172,14 +157,169 @@ describe("Sitemap Generator (app/sitemap.ts)", () => {
delete process.env.NEXT_PUBLIC_SITE_URL delete process.env.NEXT_PUBLIC_SITE_URL
}) })
it("uses custom NEXT_PUBLIC_SITE_URL when set to non-localhost", async () => { it("uses custom NEXT_PUBLIC_SITE_URL for staging", async () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz" process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz"
const mod = await import("@/app/sitemap") const mod = await import("@/app/sitemap")
const result = await mod.default() const result = await mod.default({ id: Promise.resolve("static") })
expect(result[0].url).toContain("staging.deckyvault.xyz") expect(result[0].url).toContain("staging.deckyvault.xyz")
delete process.env.NEXT_PUBLIC_SITE_URL delete process.env.NEXT_PUBLIC_SITE_URL
}) })
})
// ── sitemap({ id: 'games' }) ───────────────────────────────────────
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") })
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)
})
})
// ── sitemap({ id: 'devices' }) ─────────────────────────────────────
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") })
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)
})
})
// ── sitemap({ id: 'updates' }) ─────────────────────────────────────
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") })
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)
})
})
// ── Isolation ──────────────────────────────────────────────────────
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") })
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([])
})
})
}) })
+143 -30
View File
@@ -14,26 +14,112 @@ import {
/** Revalidate sitemap every hour via ISR */ /** Revalidate sitemap every hour via ISR */
export const revalidate = 3600 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 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<MetadataRoute.Sitemap> { // ─── Sitemap Index Generator ────────────────────────────────────────
const baseUrl = getBaseUrl()
const entries: MetadataRoute.Sitemap = []
// ── 1. Static pages ──────────────────────────────────────────────── /**
for (const page of STATIC_PAGES) { * Returns the list of child sitemap IDs. Next.js auto-generates
entries.push({ * the sitemap index at /sitemap.xml from this.
url: page.urlPath ? `${baseUrl}${page.urlPath}` : baseUrl, */
changeFrequency: page.changeFrequency, export async function generateSitemaps(): Promise<{ id: string }[]> {
priority: page.priority, 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) ─────────────────────────── return ids
const gameRows = await querySafe("games", () => }
// ─── Child Sitemap Generator ────────────────────────────────────────
export default async function sitemap(props: {
id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
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<MetadataRoute.Sitemap> {
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<MetadataRoute.Sitemap> {
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 db
.select({ .select({
id: games.id, id: games.id,
@@ -44,21 +130,33 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))), .where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))),
) )
if (gameRows) { if (!rows) return []
for (const row of gameRows) {
const entries: MetadataRoute.Sitemap = []
for (const row of rows) {
if (entries.length >= MAX_ENTRIES) break if (entries.length >= MAX_ENTRIES) break
entries.push({ entries.push({
url: `${baseUrl}/game/${row.id}`, url: `${baseUrl}/game/${row.id}`,
lastModified: toDate(row.updatedAt), lastModified: toDate(row.updatedAt),
changeFrequency: "weekly", changeFrequency: "weekly" as const,
priority: 0.8, priority: 0.8,
...imageEntry(row.capsuleImage), ...imageEntry(row.capsuleImage),
}) })
} }
}
// ── 3. Device detail pages (from database) ───────────────────────── // Apply pagination slice
const deviceRows = await querySafe("hardware", () => const sliced = entries.slice(offset, offset + GAMES_PER_SITEMAP)
return sliced
}
/**
* Devices child sitemap — DB-backed with ISR caching.
*/
async function generateDevicesSitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
const rows = await querySafe("hardware", () =>
db db
.select({ .select({
slug: hardware.slug, slug: hardware.slug,
@@ -67,33 +165,48 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
.from(hardware), .from(hardware),
) )
if (deviceRows) { if (!rows) return []
for (const row of deviceRows) {
const entries: MetadataRoute.Sitemap = []
for (const row of rows) {
if (entries.length >= MAX_ENTRIES) break if (entries.length >= MAX_ENTRIES) break
entries.push({ entries.push({
url: `${baseUrl}/devices/${row.slug}`, url: `${baseUrl}/devices/${row.slug}`,
lastModified: toDate(row.createdAt), lastModified: toDate(row.createdAt),
changeFrequency: "monthly", changeFrequency: "monthly" as const,
priority: 0.6, priority: 0.6,
}) })
} }
return entries
}
/**
* Updates child sitemap — filesystem-backed with ISR caching.
*/
async function generateUpdatesSitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
let updates: ReturnType<typeof getAllUpdates>
try {
updates = getAllUpdates()
} catch (err) {
console.error("[Sitemap] Failed to load updates:", err)
return []
} }
// ── 4. Update / changelog detail pages (from markdown files) ─────── const entries: MetadataRoute.Sitemap = []
try {
const updates = getAllUpdates()
for (const update of updates) { for (const update of updates) {
if (entries.length >= MAX_ENTRIES) break if (entries.length >= MAX_ENTRIES) break
entries.push({ entries.push({
url: `${baseUrl}/updates/${update.slug}`, url: `${baseUrl}/updates/${update.slug}`,
lastModified: toDate(update.date), lastModified: toDate(update.date),
changeFrequency: "monthly", changeFrequency: "monthly" as const,
priority: 0.5, priority: 0.5,
}) })
} }
} catch (err) {
console.error("[Sitemap] Failed to load updates:", err)
}
return entries return entries
} }