feat: extract sitemap utilities to lib/sitemap-utils.ts (Task 1)
This commit is contained in:
+7
-87
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user