From 0f94829a4b344cec4e14b7d89f9e6c1b32355467 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 13 Aug 2026 13:36:18 +0000 Subject: [PATCH] Fix sitemap.xml 500s from cookies() and invalid lastmod Stop using the cookie-based Supabase server client in the cached sitemap route. Fetch published mashups with the public anon client, omit invalid dates, and always fall back to static URLs so the response stays valid XML even with zero generations. Co-authored-by: Chinmay Kabi --- src/app/sitemap.ts | 54 ++----- src/lib/__tests__/sitemap-data.test.ts | 187 ++++++++++++++++++++++++ src/lib/sitemap-data.ts | 194 +++++++++++++++++++------ 3 files changed, 345 insertions(+), 90 deletions(-) create mode 100644 src/lib/__tests__/sitemap-data.test.ts diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 0593ad7..90d58a6 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,8 +1,9 @@ import type { MetadataRoute } from "next"; import { - getPublicCreatorsForSitemap, - getPublishedGenerationsForSitemap, + buildSitemapEntries, + getPublicSitemapEntries, + getStaticSitemapEntries, } from "@/lib/sitemap-data"; import { getSiteUrl } from "@/lib/site-url"; @@ -11,47 +12,10 @@ export const revalidate = 3600; export default async function sitemap(): Promise { const base = getSiteUrl(); - const staticEntries: MetadataRoute.Sitemap = [ - { - url: base, - changeFrequency: "daily", - priority: 1, - }, - { - url: `${base}/feed`, - changeFrequency: "hourly", - priority: 0.9, - }, - { - url: `${base}/generate`, - changeFrequency: "weekly", - priority: 0.85, - }, - { - url: `${base}/about`, - changeFrequency: "monthly", - priority: 0.6, - }, - ]; - - const [generations, creators] = await Promise.all([ - getPublishedGenerationsForSitemap(), - getPublicCreatorsForSitemap(), - ]); - - const generationEntries: MetadataRoute.Sitemap = generations.map((g) => ({ - url: `${base}/g/${encodeURIComponent(g.slug)}`, - lastModified: g.updatedAt, - changeFrequency: "weekly" as const, - priority: 0.7, - })); - - const creatorEntries: MetadataRoute.Sitemap = creators.map((c) => ({ - url: `${base}/u/${encodeURIComponent(c.id)}`, - lastModified: c.updatedAt, - changeFrequency: "weekly" as const, - priority: 0.5, - })); - - return [...staticEntries, ...generationEntries, ...creatorEntries]; + try { + const { generations, creators } = await getPublicSitemapEntries(); + return buildSitemapEntries(base, generations, creators); + } catch { + return getStaticSitemapEntries(base); + } } diff --git a/src/lib/__tests__/sitemap-data.test.ts b/src/lib/__tests__/sitemap-data.test.ts new file mode 100644 index 0000000..f43786f --- /dev/null +++ b/src/lib/__tests__/sitemap-data.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + buildSitemapEntries, + getPublicSitemapEntries, + getStaticSitemapEntries, + mapPublishedRowsToSitemapEntries, + parseSitemapLastmod, +} from "@/lib/sitemap-data"; + +const { createPublicFeedClient } = vi.hoisted(() => ({ + createPublicFeedClient: vi.fn(), +})); + +vi.mock("@/lib/feed-client", () => ({ + createPublicFeedClient, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("parseSitemapLastmod", () => { + it("returns an ISO string for valid timestamps", () => { + expect(parseSitemapLastmod("2026-08-11T19:41:41.990Z")).toBe( + "2026-08-11T19:41:41.990Z", + ); + }); + + it("omits invalid, empty, and null dates so XML serialization cannot throw", () => { + expect(parseSitemapLastmod(undefined)).toBeUndefined(); + expect(parseSitemapLastmod(null)).toBeUndefined(); + expect(parseSitemapLastmod("")).toBeUndefined(); + expect(parseSitemapLastmod("not-a-date")).toBeUndefined(); + expect(parseSitemapLastmod(new Date("invalid"))).toBeUndefined(); + }); +}); + +describe("getStaticSitemapEntries", () => { + it("always includes marketing routes even with zero mashups", () => { + const entries = getStaticSitemapEntries("https://xbuildsy.com"); + expect(entries.map((e) => e.url)).toEqual([ + "https://xbuildsy.com", + "https://xbuildsy.com/feed", + "https://xbuildsy.com/generate", + "https://xbuildsy.com/about", + ]); + }); +}); + +describe("mapPublishedRowsToSitemapEntries", () => { + it("skips blank slugs and keeps the newest lastmod per creator", () => { + const { generations, creators } = mapPublishedRowsToSitemapEntries([ + { + slug: "youtube-figma", + creator_id: "creator-a", + updated_at: "2026-08-11T19:41:39.026Z", + }, + { + slug: " ", + creator_id: "creator-a", + updated_at: "2026-08-12T00:00:00.000Z", + }, + { + slug: "apple-twitter-x", + creator_id: "creator-a", + updated_at: "2026-08-10T00:00:00.000Z", + }, + { + slug: "orphan-mashup", + creator_id: null, + updated_at: "not-a-date", + }, + ]); + + expect(generations).toEqual([ + { + slug: "youtube-figma", + updatedAt: "2026-08-11T19:41:39.026Z", + }, + { + slug: "apple-twitter-x", + updatedAt: "2026-08-10T00:00:00.000Z", + }, + { slug: "orphan-mashup", updatedAt: undefined }, + ]); + expect(creators).toEqual([ + { id: "creator-a", updatedAt: "2026-08-11T19:41:39.026Z" }, + ]); + }); +}); + +describe("buildSitemapEntries", () => { + it("returns only static URLs when generations are empty", () => { + const entries = buildSitemapEntries("https://xbuildsy.com", [], []); + expect(entries).toEqual(getStaticSitemapEntries("https://xbuildsy.com")); + }); + + it("omits lastModified when the timestamp is missing", () => { + const entries = buildSitemapEntries( + "https://xbuildsy.com", + [{ slug: "youtube-figma" }], + [{ id: "creator-a" }], + ); + expect(entries.some((e) => "lastModified" in e)).toBe(false); + expect(entries.map((e) => e.url)).toContain( + "https://xbuildsy.com/g/youtube-figma", + ); + expect(entries.map((e) => e.url)).toContain( + "https://xbuildsy.com/u/creator-a", + ); + }); + + it("encodes path segments", () => { + const entries = buildSitemapEntries( + "https://xbuildsy.com", + [{ slug: "a/b c", updatedAt: "2026-01-01T00:00:00.000Z" }], + [], + ); + expect(entries.map((e) => e.url)).toContain( + "https://xbuildsy.com/g/a%2Fb%20c", + ); + }); +}); + +describe("getPublicSitemapEntries", () => { + it("returns empty lists when the public Supabase client is unavailable", async () => { + createPublicFeedClient.mockReturnValue(null); + await expect(getPublicSitemapEntries()).resolves.toEqual({ + generations: [], + creators: [], + }); + }); + + it("returns empty lists when the generations query throws", async () => { + createPublicFeedClient.mockReturnValue({ + from: () => { + throw new Error("supabase down"); + }, + }); + await expect(getPublicSitemapEntries()).resolves.toEqual({ + generations: [], + creators: [], + }); + }); + + it("maps published rows from a successful query", async () => { + const result = { + data: [ + { + slug: "youtube-figma", + creator_id: "creator-a", + updated_at: "2026-08-11T19:41:39.026Z", + }, + ], + error: null, + }; + const query: { + eq: ReturnType; + order: ReturnType; + limit: ReturnType; + } = { + eq: vi.fn(), + order: vi.fn(), + limit: vi.fn().mockResolvedValue(result), + }; + query.eq.mockReturnValue(query); + query.order.mockReturnValue(query); + createPublicFeedClient.mockReturnValue({ + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue(query), + }), + }); + + await expect(getPublicSitemapEntries()).resolves.toEqual({ + generations: [ + { + slug: "youtube-figma", + updatedAt: "2026-08-11T19:41:39.026Z", + }, + ], + creators: [ + { id: "creator-a", updatedAt: "2026-08-11T19:41:39.026Z" }, + ], + }); + }); +}); diff --git a/src/lib/sitemap-data.ts b/src/lib/sitemap-data.ts index 8b70f73..88c1e26 100644 --- a/src/lib/sitemap-data.ts +++ b/src/lib/sitemap-data.ts @@ -1,56 +1,160 @@ -import { createSupabaseServerClient } from "@/lib/supabase/server"; - -export async function getPublishedGenerationsForSitemap(): Promise< - { slug: string; updatedAt: Date }[] -> { - const supabase = await createSupabaseServerClient(); - const { data, error } = await supabase - .from("generations") - .select("slug, updated_at") - .eq("visibility", "published") - .eq("moderation_status", "visible") - .eq("status", "completed") - .eq("image_ready", true) - .order("updated_at", { ascending: false }) - .limit(50_000); +import { createPublicFeedClient } from "@/lib/feed-client"; +import { publicGenerationsQuery } from "@/lib/feed-public-filters"; - if (error || !data) return []; +/** Leave headroom under Google's 50_000 URL sitemap cap for static + creator URLs. */ +const SITEMAP_GENERATION_LIMIT = 40_000; - return data.map((row) => ({ - slug: row.slug, - updatedAt: new Date(row.updated_at), - })); +export type SitemapUrlEntry = { + url: string; + lastModified?: string; + changeFrequency: + | "always" + | "hourly" + | "daily" + | "weekly" + | "monthly" + | "yearly" + | "never"; + priority: number; +}; + +export type SitemapGenerationRow = { + slug: string; + updatedAt?: string; +}; + +export type SitemapCreatorRow = { + id: string; + updatedAt?: string; +}; + +type PublishedSitemapRow = { + slug: unknown; + creator_id: unknown; + updated_at: unknown; +}; + +export function parseSitemapLastmod(value: unknown): string | undefined { + if (value == null || value === "") return undefined; + const date = value instanceof Date ? value : new Date(String(value)); + if (Number.isNaN(date.getTime())) return undefined; + return date.toISOString(); } -/** Creators with at least one published generation (for public profile URLs). */ -export async function getPublicCreatorsForSitemap(): Promise< - { id: string; updatedAt: Date }[] -> { - const supabase = await createSupabaseServerClient(); - const { data, error } = await supabase - .from("generations") - .select("creator_id, updated_at") - .eq("visibility", "published") - .eq("moderation_status", "visible") - .eq("status", "completed") - .eq("image_ready", true) - .order("updated_at", { ascending: false }) - .limit(50_000); +function isSitemapPathSegment(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} - if (error || !data) return []; +export function getStaticSitemapEntries(base: string): SitemapUrlEntry[] { + return [ + { + url: base, + changeFrequency: "daily", + priority: 1, + }, + { + url: `${base}/feed`, + changeFrequency: "hourly", + priority: 0.9, + }, + { + url: `${base}/generate`, + changeFrequency: "weekly", + priority: 0.85, + }, + { + url: `${base}/about`, + changeFrequency: "monthly", + priority: 0.6, + }, + ]; +} - const byCreator = new Map(); - for (const row of data) { - const creatorId = row.creator_id as string; - const updatedAt = new Date(row.updated_at); - const prev = byCreator.get(creatorId); - if (!prev || updatedAt > prev) { - byCreator.set(creatorId, updatedAt); +export function mapPublishedRowsToSitemapEntries( + rows: PublishedSitemapRow[], +): { + generations: SitemapGenerationRow[]; + creators: SitemapCreatorRow[]; +} { + const generations: SitemapGenerationRow[] = []; + const byCreator = new Map(); + + for (const row of rows) { + if (!isSitemapPathSegment(row.slug)) continue; + const updatedAt = parseSitemapLastmod(row.updated_at); + generations.push({ slug: row.slug, updatedAt }); + + if (!isSitemapPathSegment(row.creator_id)) continue; + const prev = byCreator.get(row.creator_id); + if (!prev || (updatedAt && updatedAt > prev)) { + byCreator.set(row.creator_id, updatedAt); } } - return [...byCreator.entries()].map(([id, updatedAt]) => ({ - id, - updatedAt, + return { + generations, + creators: [...byCreator.entries()].map(([id, updatedAt]) => ({ + id, + updatedAt, + })), + }; +} + +export function buildSitemapEntries( + base: string, + generations: SitemapGenerationRow[], + creators: SitemapCreatorRow[], +): SitemapUrlEntry[] { + const generationEntries: SitemapUrlEntry[] = generations.map((g) => ({ + url: `${base}/g/${encodeURIComponent(g.slug)}`, + ...(g.updatedAt ? { lastModified: g.updatedAt } : {}), + changeFrequency: "weekly", + priority: 0.7, + })); + + const creatorEntries: SitemapUrlEntry[] = creators.map((c) => ({ + url: `${base}/u/${encodeURIComponent(c.id)}`, + ...(c.updatedAt ? { lastModified: c.updatedAt } : {}), + changeFrequency: "weekly", + priority: 0.5, })); + + return [ + ...getStaticSitemapEntries(base), + ...generationEntries, + ...creatorEntries, + ]; +} + +async function fetchPublishedSitemapRows(): Promise { + const supabase = createPublicFeedClient(); + if (!supabase) return []; + + const { data, error } = await publicGenerationsQuery( + supabase, + "slug, creator_id, updated_at", + ) + .order("updated_at", { ascending: false }) + .limit(SITEMAP_GENERATION_LIMIT); + + if (error || !data) return []; + return data as PublishedSitemapRow[]; +} + +/** + * Public mashup + creator URLs for the sitemap. + * Uses the cookie-less anon client so `/sitemap.xml` can be cached (ISR) and + * never depends on `cookies()` / request-time APIs. Failures return empty lists + * so the static routes still render as valid XML. + */ +export async function getPublicSitemapEntries(): Promise<{ + generations: SitemapGenerationRow[]; + creators: SitemapCreatorRow[]; +}> { + try { + const rows = await fetchPublishedSitemapRows(); + return mapPublishedRowsToSitemapEntries(rows); + } catch { + return { generations: [], creators: [] }; + } }