feat: extract sitemap utilities to lib/sitemap-utils.ts (Task 1)

This commit is contained in:
2026-05-25 21:16:17 +08:00
parent bc1fe0b72b
commit 1ed90bb222
3 changed files with 306 additions and 87 deletions
+7 -87
View File
@@ -3,10 +3,13 @@ import { db } from "@/lib/db/index"
import { games, hardware } from "@/lib/db/schema" import { games, hardware } from "@/lib/db/schema"
import { or, ne, isNull } from "drizzle-orm" import { or, ne, isNull } from "drizzle-orm"
import { getAllUpdates } from "@/lib/updates" import { getAllUpdates } from "@/lib/updates"
import {
// ─── Configuration ───────────────────────────────────────────────────── getBaseUrl,
imageEntry,
const PRODUCTION_URL = "https://deckyvault.xyz" toDate,
querySafe,
STATIC_PAGES,
} from "@/lib/sitemap-utils"
/** Revalidate sitemap every hour via ISR */ /** Revalidate sitemap every hour via ISR */
export const revalidate = 3600 export const revalidate = 3600
@@ -14,89 +17,6 @@ export const revalidate = 3600
/** Maximum entries per sitemap (Google's limit is 50k; we stay well under) */ /** Maximum entries per sitemap (Google's limit is 50k; we stay well under) */
const MAX_ENTRIES = 45_000 const MAX_ENTRIES = 45_000
// ─── Helpers ────────────────────────────────────────────────────────────
function getBaseUrl(): string {
const envUrl = process.env.NEXT_PUBLIC_SITE_URL
if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) {
return envUrl.replace(/\/$/, "")
}
return PRODUCTION_URL
}
/** Build a valid image sitemap entry from a capsule image URL */
function imageEntry(
capsuleImage: unknown,
): { images: string[] } | Record<string, never> {
if (
typeof capsuleImage === "string" &&
capsuleImage.trim().startsWith("https://") &&
capsuleImage.trim().length <= 2048
) {
return { images: [capsuleImage.trim()] }
}
return {}
}
/** Safely extract a Date from a value that could be Date, string, or nullish */
function toDate(value: unknown): Date | undefined {
if (value instanceof Date && !Number.isNaN(value.getTime())) return value
if (typeof value === "string" || typeof value === "number") {
const d = new Date(value)
if (!Number.isNaN(d.getTime())) return d
}
return undefined
}
/**
* Run a DB query with a safety net.
* Returns rows on success, undefined on failure — the sitemap still
* renders with whatever data is available.
*/
async function querySafe<T>(
label: string,
query: () => Promise<T>,
timeoutMs = 15_000,
): Promise<T | undefined> {
try {
const result = await Promise.race([
query(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`[Sitemap] ${label} query timed out after ${timeoutMs}ms`)),
timeoutMs,
),
),
])
return result
} catch (err) {
console.error(`[Sitemap] ${label} query failed:`, err)
return undefined
}
}
// ─── Static page definitions ───────────────────────────────────────────
interface StaticPageDef {
urlPath: string
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]
priority: number
}
const STATIC_PAGES: StaticPageDef[] = [
// Homepage
{ urlPath: "", changeFrequency: "weekly", priority: 1.0 },
// Core browse pages
{ urlPath: "/games", changeFrequency: "daily", priority: 0.9 },
{ urlPath: "/devices", changeFrequency: "weekly", priority: 0.7 },
{ urlPath: "/updates", changeFrequency: "weekly", priority: 0.6 },
// Utility pages
{ urlPath: "/compare", changeFrequency: "weekly", priority: 0.5 },
{ urlPath: "/search", changeFrequency: "monthly", priority: 0.3 },
// Static content
{ urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 },
]
// ─── Sitemap builder (called by Next.js on every request + ISR) ──────── // ─── Sitemap builder (called by Next.js on every request + ISR) ────────
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
+211
View File
@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import {
PRODUCTION_URL,
getBaseUrl,
imageEntry,
toDate,
querySafe,
STATIC_PAGES,
} from "@/lib/sitemap-utils"
describe("PRODUCTION_URL", () => {
it("is the canonical production URL", () => {
expect(PRODUCTION_URL).toBe("https://deckyvault.xyz")
})
})
describe("getBaseUrl", () => {
afterEach(() => {
delete process.env.NEXT_PUBLIC_SITE_URL
})
it("returns PRODUCTION_URL when no env var is set", () => {
expect(getBaseUrl()).toBe("https://deckyvault.xyz")
})
it("returns PRODUCTION_URL when env var is localhost", () => {
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000"
expect(getBaseUrl()).toBe("https://deckyvault.xyz")
})
it("returns PRODUCTION_URL when env var is 127.0.0.1", () => {
process.env.NEXT_PUBLIC_SITE_URL = "http://127.0.0.1:8080"
expect(getBaseUrl()).toBe("https://deckyvault.xyz")
})
it("returns the custom env var when set to a real domain", () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz"
expect(getBaseUrl()).toBe("https://staging.deckyvault.xyz")
})
it("strips trailing slash from env var", () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz/"
expect(getBaseUrl()).toBe("https://staging.deckyvault.xyz")
})
})
describe("imageEntry", () => {
it("returns an images array for valid HTTPS capsule URLs", () => {
const result = imageEntry("https://cdn.example.com/capsule.jpg")
expect(result).toEqual({ images: ["https://cdn.example.com/capsule.jpg"] })
})
it("trims whitespace from the URL", () => {
const result = imageEntry(" https://cdn.example.com/capsule.jpg ")
expect(result).toEqual({ images: ["https://cdn.example.com/capsule.jpg"] })
})
it("returns empty object for non-HTTPS URLs", () => {
const result = imageEntry("http://cdn.example.com/capsule.jpg")
expect(result).toEqual({})
})
it("returns empty object for non-string values", () => {
expect(imageEntry(null)).toEqual({})
expect(imageEntry(undefined)).toEqual({})
expect(imageEntry(123)).toEqual({})
expect(imageEntry({})).toEqual({})
})
it("returns empty object for empty strings", () => {
expect(imageEntry("")).toEqual({})
expect(imageEntry(" ")).toEqual({})
})
it("returns empty object for URLs exceeding 2048 characters", () => {
const longUrl = "https://cdn.example.com/" + "a".repeat(2048)
expect(imageEntry(longUrl)).toEqual({})
})
it("accepts URLs exactly at the 2048 character limit", () => {
const maxUrl = "https://cdn.example.com/" + "a".repeat(2048 - 26)
expect(imageEntry(maxUrl)).toEqual({ images: [maxUrl] })
})
})
describe("toDate", () => {
it("returns the same Date object if passed a valid Date", () => {
const d = new Date("2025-01-15T10:00:00Z")
expect(toDate(d)).toBe(d)
})
it("returns undefined for invalid Date objects", () => {
const d = new Date("not-a-date")
expect(toDate(d)).toBeUndefined()
})
it("parses a valid ISO date string", () => {
const result = toDate("2025-01-15T10:00:00Z")
expect(result).toBeInstanceOf(Date)
expect(result!.toISOString()).toBe("2025-01-15T10:00:00.000Z")
})
it("returns undefined for invalid date strings", () => {
expect(toDate("not-a-date")).toBeUndefined()
})
it("parses a numeric timestamp", () => {
const result = toDate(1700000000000)
expect(result).toBeInstanceOf(Date)
expect(result!.getTime()).toBe(1700000000000)
})
it("returns undefined for null", () => {
expect(toDate(null)).toBeUndefined()
})
it("returns undefined for undefined", () => {
expect(toDate(undefined)).toBeUndefined()
})
it("returns undefined for non-date objects", () => {
expect(toDate({ foo: "bar" })).toBeUndefined()
})
})
describe("querySafe", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("returns the query result on success", async () => {
const query = vi.fn().mockResolvedValue([{ id: 1 }])
const result = await querySafe("test", query)
expect(result).toEqual([{ id: 1 }])
expect(query).toHaveBeenCalledOnce()
})
it("returns undefined when the query throws", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const query = vi.fn().mockRejectedValue(new Error("DB down"))
const result = await querySafe("test", query)
expect(result).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[Sitemap] test query failed:",
expect.any(Error),
)
consoleErrorSpy.mockRestore()
})
it("returns undefined when the query times out", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const query = vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve([{ id: 1 }]), 20_000)),
)
const resultPromise = querySafe("test", query, 5_000)
vi.advanceTimersByTime(5_001)
const result = await resultPromise
expect(result).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[Sitemap] test query failed:",
expect.any(Error),
)
consoleErrorSpy.mockRestore()
})
it("uses default timeout of 15s when not specified", async () => {
const query = vi.fn().mockResolvedValue([{ id: 1 }])
const result = await querySafe("test", query)
expect(result).toEqual([{ id: 1 }])
})
})
describe("STATIC_PAGES", () => {
it("contains exactly 7 entries", () => {
expect(STATIC_PAGES).toHaveLength(7)
})
it("first entry is the homepage with empty urlPath and priority 1", () => {
expect(STATIC_PAGES[0].urlPath).toBe("")
expect(STATIC_PAGES[0].priority).toBe(1.0)
})
it("includes /games with priority 0.9", () => {
const entry = STATIC_PAGES.find((p) => p.urlPath === "/games")
expect(entry).toBeDefined()
expect(entry!.priority).toBe(0.9)
})
it("includes /compare with priority 0.5", () => {
const entry = STATIC_PAGES.find((p) => p.urlPath === "/compare")
expect(entry).toBeDefined()
expect(entry!.priority).toBe(0.5)
})
it("includes /search with priority 0.3", () => {
const entry = STATIC_PAGES.find((p) => p.urlPath === "/search")
expect(entry).toBeDefined()
expect(entry!.priority).toBe(0.3)
})
it("every entry has a valid changeFrequency", () => {
const validFreqs = ["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]
for (const page of STATIC_PAGES) {
expect(validFreqs).toContain(page.changeFrequency)
}
})
})
+88
View File
@@ -0,0 +1,88 @@
import type { MetadataRoute } from "next"
// ─── Configuration ─────────────────────────────────────────────────────
export const PRODUCTION_URL = "https://deckyvault.xyz"
// ─── Helpers ───────────────────────────────────────────────────────────
export function getBaseUrl(): string {
const envUrl = process.env.NEXT_PUBLIC_SITE_URL
if (envUrl && !envUrl.includes("localhost") && !envUrl.includes("127.0.0.1")) {
return envUrl.replace(/\/$/, "")
}
return PRODUCTION_URL
}
/** Build a valid image sitemap entry from a capsule image URL */
export function imageEntry(
capsuleImage: unknown,
): { images: string[] } | Record<string, never> {
if (
typeof capsuleImage === "string" &&
capsuleImage.trim().startsWith("https://") &&
capsuleImage.trim().length <= 2048
) {
return { images: [capsuleImage.trim()] }
}
return {}
}
/** Safely extract a Date from a value that could be Date, string, or nullish */
export function toDate(value: unknown): Date | undefined {
if (value instanceof Date && !Number.isNaN(value.getTime())) return value
if (typeof value === "string" || typeof value === "number") {
const d = new Date(value)
if (!Number.isNaN(d.getTime())) return d
}
return undefined
}
/**
* Run a DB query with a safety net.
* Returns rows on success, undefined on failure — the sitemap still
* renders with whatever data is available.
*/
export async function querySafe<T>(
label: string,
query: () => Promise<T>,
timeoutMs = 15_000,
): Promise<T | undefined> {
try {
const result = await Promise.race([
query(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`[Sitemap] ${label} query timed out after ${timeoutMs}ms`)),
timeoutMs,
),
),
])
return result
} catch (err) {
console.error(`[Sitemap] ${label} query failed:`, err)
return undefined
}
}
// ─── Static page definitions ───────────────────────────────────────────
export interface StaticPageDef {
urlPath: string
changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]
priority: number
}
export const STATIC_PAGES: StaticPageDef[] = [
// Homepage
{ urlPath: "", changeFrequency: "weekly", priority: 1.0 },
// Core browse pages
{ urlPath: "/games", changeFrequency: "daily", priority: 0.9 },
{ urlPath: "/devices", changeFrequency: "weekly", priority: 0.7 },
{ urlPath: "/updates", changeFrequency: "weekly", priority: 0.6 },
// Utility pages
{ urlPath: "/compare", changeFrequency: "weekly", priority: 0.5 },
{ urlPath: "/search", changeFrequency: "monthly", priority: 0.3 },
// Static content
{ urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 },
]