import 'server-only'; import { api, safe } from '@/lib/api'; import { SITE_URL, routes } from '@/lib/site'; /** * Sharded sitemap data. Shard 0 = static pages + non-satellite entities; shards 1..N = on-orbit satellites, * SATELLITES_PER_SHARD per shard (N from the API total). Served by `app/sitemap.xml/route.ts` (index) and * `app/sitemap/[id]/route.ts` (shards) — Next 16's `generateSitemaps` cannot emit an index at /sitemap.xml, which robots.txt requires. * If the API is unreachable a shard degrades to static pages only; URLs are never fabricated. */ export const SATELLITES_PER_SHARD = 5000; type ChangeFreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; export interface SitemapEntry { url: string; lastModified?: Date; changeFrequency?: ChangeFreq; priority?: number; } const STATIC: { path: string; priority: number; changeFrequency: ChangeFreq }[] = [ { path: routes.home(), priority: 1, changeFrequency: 'hourly' }, { path: routes.explore(), priority: 0.9, changeFrequency: 'hourly' }, { path: routes.satellites(), priority: 0.9, changeFrequency: 'hourly' }, { path: routes.constellations(), priority: 0.8, changeFrequency: 'daily' }, { path: routes.operators(), priority: 0.8, changeFrequency: 'daily' }, { path: routes.countries(), priority: 0.8, changeFrequency: 'daily' }, { path: routes.launches(), priority: 0.8, changeFrequency: 'daily' }, { path: routes.launchSites(), priority: 0.6, changeFrequency: 'weekly' }, { path: routes.debris(), priority: 0.7, changeFrequency: 'daily' }, { path: routes.reentries(), priority: 0.7, changeFrequency: 'daily' }, { path: routes.events(), priority: 0.7, changeFrequency: 'hourly' }, { path: routes.stats(), priority: 0.7, changeFrequency: 'daily' }, { path: routes.rankings(), priority: 0.6, changeFrequency: 'daily' }, { path: routes.sources(), priority: 0.5, changeFrequency: 'weekly' }, { path: routes.methodology(), priority: 0.5, changeFrequency: 'monthly' }, { path: routes.status(), priority: 0.3, changeFrequency: 'hourly' }, { path: routes.developers(), priority: 0.5, changeFrequency: 'monthly' }, { path: routes.about(), priority: 0.4, changeFrequency: 'monthly' }, { path: routes.privacy(), priority: 0.2, changeFrequency: 'yearly' }, { path: routes.terms(), priority: 0.2, changeFrequency: 'yearly' }, ]; const abs = (path: string) => `${SITE_URL}${path}`; const date = (v: string | null | undefined) => (v && !Number.isNaN(new Date(v).getTime()) ? new Date(v) : undefined); /** Number of satellite shards (≥ 1 even when the API is down, so /sitemap/1.xml always exists). */ export async function satelliteShardCount(): Promise { const first = await safe(api.sitemapSatellites(1, SATELLITES_PER_SHARD)); const total = first?.data.total ?? 0; return Math.max(1, Math.ceil(total / SATELLITES_PER_SHARD)); } export async function shardEntries(shard: number): Promise { if (shard === 0) { const out: SitemapEntry[] = STATIC.map((s) => ({ url: abs(s.path), changeFrequency: s.changeFrequency, priority: s.priority })); const ents = await safe(api.sitemapEntities()); if (ents) { const d = ents.data; out.push(...d.constellations.map((c) => ({ url: abs(routes.constellation(c.slug)), lastModified: date(c.updated_at), changeFrequency: 'daily' as const, priority: 0.8 }))); out.push(...d.operators.map((o) => ({ url: abs(routes.operator(o.slug)), lastModified: date(o.updated_at), changeFrequency: 'weekly' as const, priority: 0.7 }))); out.push(...d.countries.map((c) => ({ url: abs(routes.country(c.slug)), changeFrequency: 'weekly' as const, priority: 0.7 }))); out.push(...d.launches.map((l) => ({ url: abs(routes.launch(l.slug)), lastModified: date(l.updated_at), changeFrequency: 'weekly' as const, priority: 0.5 }))); out.push(...d.launch_sites.map((s) => ({ url: abs(routes.launchSite(s.slug)), changeFrequency: 'monthly' as const, priority: 0.5 }))); } return out; } const page = await safe(api.sitemapSatellites(shard, SATELLITES_PER_SHARD)); if (!page) return []; return page.data.items.map((s) => ({ url: abs(routes.satellite(s.slug)), lastModified: date(s.updated_at), changeFrequency: s.status === 'ACTIVE' ? 'daily' : 'weekly', priority: s.status === 'ACTIVE' ? 0.6 : 0.4, })); } const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); export function toUrlset(entries: SitemapEntry[]): string { const body = entries .map((e) => { const parts = [`${esc(e.url)}`]; if (e.lastModified) parts.push(`${e.lastModified.toISOString()}`); if (e.changeFrequency) parts.push(`${e.changeFrequency}`); if (e.priority !== undefined) parts.push(`${e.priority}`); return `${parts.join('')}`; }) .join('\n'); return `\n\n${body}\n\n`; } export function toIndex(shardIds: number[], lastmod: Date = new Date()): string { const body = shardIds.map((id) => `${esc(`${SITE_URL}/sitemap/${id}.xml`)}${lastmod.toISOString()}`).join('\n'); return `\n\n${body}\n\n`; } export const SITEMAP_HEADERS = { 'content-type': 'application/xml; charset=utf-8', 'cache-control': 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400' };