feat: add static sitemap generation script, replace dynamic sitemap with static files
This commit is contained in:
+8
-112
@@ -1,114 +1,10 @@
|
||||
// Static sitemap is generated at build time and served from /public/sitemap.xml
|
||||
// This file is intentionally left as a redirect to the static file.
|
||||
// Run `npm run build:sitemap` before `npm run build` to regenerate.
|
||||
import type { MetadataRoute } from "next"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games, hardware } from "@/lib/db/schema"
|
||||
import { or, ne, isNull } from "drizzle-orm"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
|
||||
|
||||
function buildStaticEntries(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: BASE_URL,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/games`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily" as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/devices`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.6,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/updates`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${BASE_URL}/contact`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "yearly" as const,
|
||||
priority: 0.3,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const staticEntries = buildStaticEntries()
|
||||
|
||||
try {
|
||||
const [gameRows, deviceRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: games.id,
|
||||
updatedAt: games.updatedAt,
|
||||
capsuleImage: games.capsuleImage,
|
||||
})
|
||||
.from(games)
|
||||
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))),
|
||||
db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
createdAt: hardware.createdAt,
|
||||
})
|
||||
.from(hardware),
|
||||
])
|
||||
|
||||
const gameEntries: MetadataRoute.Sitemap = gameRows.map((row) => {
|
||||
const image =
|
||||
row.capsuleImage &&
|
||||
typeof row.capsuleImage === "string" &&
|
||||
row.capsuleImage.trim().startsWith("https://") &&
|
||||
row.capsuleImage.trim().length <= 2048
|
||||
? row.capsuleImage.trim()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
url: `${BASE_URL}/game/${row.id}`,
|
||||
lastModified: row.updatedAt ?? undefined,
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.7,
|
||||
...(image ? { images: [image] } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const deviceEntries: MetadataRoute.Sitemap = deviceRows.map((row) => ({
|
||||
url: `${BASE_URL}/devices/${row.slug}`,
|
||||
lastModified: row.createdAt ?? undefined,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.5,
|
||||
}))
|
||||
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
event: "sitemap_generated",
|
||||
gameCount: gameEntries.length,
|
||||
deviceCount: deviceEntries.length,
|
||||
staticCount: staticEntries.length,
|
||||
totalUrls: staticEntries.length + gameEntries.length + deviceEntries.length,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
|
||||
return [...staticEntries, ...gameEntries, ...deviceEntries]
|
||||
} catch (err) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "sitemap_db_error",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
return staticEntries
|
||||
}
|
||||
}
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
// Static sitemap is served from /public/sitemap.xml
|
||||
// This function is no longer used for dynamic sitemap generation
|
||||
return []
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
|
||||
// Mock database to prevent real queries
|
||||
vi.mock("@/lib/db/index", () => ({
|
||||
db: {
|
||||
select: vi.fn().mockReturnValue({
|
||||
@@ -22,13 +23,9 @@ vi.mock("drizzle-orm", () => ({
|
||||
}))
|
||||
|
||||
describe("Sitemap Generator", () => {
|
||||
it("exports dynamic = force-dynamic", async () => {
|
||||
it("default export returns empty array (static sitemap is in public/)", async () => {
|
||||
const mod = await import("@/app/sitemap")
|
||||
expect(mod.dynamic).toBe("force-dynamic")
|
||||
const result = mod.default()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("default export is a function", async () => {
|
||||
const mod = await import("@/app/sitemap")
|
||||
expect(typeof mod.default).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games, hardware } from "@/lib/db/schema"
|
||||
import { or, ne, isNull } from "drizzle-orm"
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
|
||||
const MAX_URLS_PER_SITEMAP = 45000 // Leave buffer below 50k limit
|
||||
|
||||
interface SitemapEntry {
|
||||
loc: string
|
||||
lastmod?: string
|
||||
changefreq?: string
|
||||
priority?: number
|
||||
"image:image"?: {
|
||||
"image:loc": string
|
||||
}
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function buildStaticEntries(): SitemapEntry[] {
|
||||
return [
|
||||
{ loc: BASE_URL, changefreq: "weekly", priority: 1.0 },
|
||||
{ loc: `${BASE_URL}/games`, changefreq: "daily", priority: 0.8 },
|
||||
{ loc: `${BASE_URL}/devices`, changefreq: "monthly", priority: 0.6 },
|
||||
{ loc: `${BASE_URL}/updates`, changefreq: "weekly", priority: 0.5 },
|
||||
{ loc: `${BASE_URL}/contact`, changefreq: "yearly", priority: 0.3 },
|
||||
]
|
||||
}
|
||||
|
||||
function renderSitemap(entries: SitemapEntry[]): string {
|
||||
const urls = entries.map((entry) => {
|
||||
let xml = ` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n`
|
||||
if (entry.lastmod) xml += ` <lastmod>${entry.lastmod}</lastmod>\n`
|
||||
if (entry.changefreq) xml += ` <changefreq>${entry.changefreq}</changefreq>\n`
|
||||
if (entry.priority) xml += ` <priority>${entry.priority}</priority>\n`
|
||||
if (entry["image:image"]) {
|
||||
xml += ` <image:image>\n <image:loc>${escapeXml(entry["image:image"]["image:loc"])}</image:loc>\n </image:image>\n`
|
||||
}
|
||||
xml += ` </url>`
|
||||
return xml
|
||||
})
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${urls.join("\n")}\n</urlset>`
|
||||
}
|
||||
|
||||
function renderSitemapIndex(sitemaps: { loc: string; lastmod: string }[]): string {
|
||||
const entries = sitemaps.map((s) => {
|
||||
return ` <sitemap>\n <loc>${escapeXml(s.loc)}</loc>\n <lastmod>${s.lastmod}</lastmod>\n </sitemap>`
|
||||
})
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join("\n")}\n</sitemapindex>`
|
||||
}
|
||||
|
||||
async function generateSitemaps() {
|
||||
console.log("Generating static sitemaps...")
|
||||
|
||||
// Fetch games
|
||||
const gameRows = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
updatedAt: games.updatedAt,
|
||||
capsuleImage: games.capsuleImage,
|
||||
})
|
||||
.from(games)
|
||||
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)))
|
||||
|
||||
// Fetch devices
|
||||
const deviceRows = await db
|
||||
.select({
|
||||
slug: hardware.slug,
|
||||
createdAt: hardware.createdAt,
|
||||
})
|
||||
.from(hardware)
|
||||
|
||||
console.log(`Found ${gameRows.length} games and ${deviceRows.length} devices`)
|
||||
|
||||
// Build game entries
|
||||
const gameEntries: SitemapEntry[] = gameRows.map((row) => {
|
||||
const image =
|
||||
row.capsuleImage &&
|
||||
typeof row.capsuleImage === "string" &&
|
||||
row.capsuleImage.trim().startsWith("https://") &&
|
||||
row.capsuleImage.trim().length <= 2048
|
||||
? row.capsuleImage.trim()
|
||||
: undefined
|
||||
|
||||
return {
|
||||
loc: `${BASE_URL}/game/${row.id}`,
|
||||
lastmod: row.updatedAt ? new Date(row.updatedAt).toISOString() : undefined,
|
||||
changefreq: "weekly",
|
||||
priority: 0.7,
|
||||
...(image ? { "image:image": { "image:loc": image } } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
// Build device entries
|
||||
const deviceEntries: SitemapEntry[] = deviceRows.map((row) => ({
|
||||
loc: `${BASE_URL}/devices/${row.slug}`,
|
||||
lastmod: row.createdAt ? new Date(row.createdAt).toISOString() : undefined,
|
||||
changefreq: "monthly",
|
||||
priority: 0.5,
|
||||
}))
|
||||
|
||||
// Static entries
|
||||
const staticEntries = buildStaticEntries()
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const publicDir = path.join(process.cwd(), "public")
|
||||
|
||||
// Ensure public dir exists
|
||||
if (!fs.existsSync(publicDir)) {
|
||||
fs.mkdirSync(publicDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Decide: single file or sitemap index
|
||||
const allDynamicEntries = [...gameEntries, ...deviceEntries]
|
||||
const needsIndex = (staticEntries.length + allDynamicEntries.length) > MAX_URLS_PER_SITEMAP
|
||||
|
||||
if (needsIndex) {
|
||||
// Write static sitemap
|
||||
const staticXml = renderSitemap(staticEntries)
|
||||
fs.writeFileSync(path.join(publicDir, "sitemap-static.xml"), staticXml)
|
||||
|
||||
// Write device sitemap
|
||||
const deviceXml = renderSitemap(deviceEntries)
|
||||
fs.writeFileSync(path.join(publicDir, "sitemap-devices.xml"), deviceXml)
|
||||
|
||||
// Split game entries into chunks
|
||||
const chunks: SitemapEntry[][] = []
|
||||
for (let i = 0; i < gameEntries.length; i += MAX_URLS_PER_SITEMAP) {
|
||||
chunks.push(gameEntries.slice(i, i + MAX_URLS_PER_SITEMAP))
|
||||
}
|
||||
|
||||
const sitemaps: { loc: string; lastmod: string }[] = [
|
||||
{ loc: `${BASE_URL}/sitemap-static.xml`, lastmod: now },
|
||||
{ loc: `${BASE_URL}/sitemap-devices.xml`, lastmod: now },
|
||||
]
|
||||
|
||||
chunks.forEach((chunk, i) => {
|
||||
const filename = `sitemap-games-${i}.xml`
|
||||
const xml = renderSitemap(chunk)
|
||||
fs.writeFileSync(path.join(publicDir, filename), xml)
|
||||
sitemaps.push({ loc: `${BASE_URL}/${filename}`, lastmod: now })
|
||||
})
|
||||
|
||||
// Write sitemap index
|
||||
const indexXml = renderSitemapIndex(sitemaps)
|
||||
fs.writeFileSync(path.join(publicDir, "sitemap.xml"), indexXml)
|
||||
} else {
|
||||
// Single sitemap
|
||||
const allEntries = [...staticEntries, ...gameEntries, ...deviceEntries]
|
||||
const xml = renderSitemap(allEntries)
|
||||
fs.writeFileSync(path.join(publicDir, "sitemap.xml"), xml)
|
||||
}
|
||||
|
||||
console.log(`Sitemaps generated successfully! Total URLs: ${staticEntries.length + allDynamicEntries.length}`)
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
generateSitemaps().catch((err) => {
|
||||
console.error("Failed to generate sitemaps:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
export { generateSitemaps }
|
||||
@@ -12,6 +12,7 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:seed": "bun run lib/db/seed.ts",
|
||||
"build:sitemap": "tsx lib/sitemap/generate-static.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
|
||||
+2083
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user