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: [] }; + } }