From deabb7233027abc66a68ed405b7f483a96868b36 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:48:50 +0000 Subject: [PATCH] feat: Implement sitemap generator --- supabase/config.toml | 3 + supabase/functions/sitemap/index.ts | 366 ++++++++++++++++++++++++++++ vercel.json | 4 + 3 files changed, 373 insertions(+) create mode 100644 supabase/functions/sitemap/index.ts diff --git a/supabase/config.toml b/supabase/config.toml index 61d22b00..ad9deb02 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,5 +1,8 @@ project_id = "ydvtmnrszybqnbcqbdcy" +[functions.sitemap] +verify_jwt = false + [functions.admin-delete-user] verify_jwt = true diff --git a/supabase/functions/sitemap/index.ts b/supabase/functions/sitemap/index.ts new file mode 100644 index 00000000..2f1555c5 --- /dev/null +++ b/supabase/functions/sitemap/index.ts @@ -0,0 +1,366 @@ +import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; + +const BASE_URL = 'https://dev.thrillwiki.com'; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +interface SitemapUrl { + loc: string; + lastmod: string; + changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; + priority: number; +} + +interface SitemapStats { + total_urls: number; + parks: number; + rides: number; + manufacturers: number; + models: number; + designers: number; + operators: number; + owners: number; + static_pages: number; + generation_time_ms: number; +} + +// ============================================================================ +// IN-MEMORY CACHE +// ============================================================================ + +let cachedSitemap: string | null = null; +let cacheTimestamp: number = 0; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +const cacheHeaders = { + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + 'CDN-Cache-Control': 'public, s-maxage=3600', + 'Vercel-CDN-Cache-Control': 'public, s-maxage=3600', +}; + +function isCacheValid(): boolean { + if (!cachedSitemap) return false; + const age = Date.now() - cacheTimestamp; + return age < CACHE_TTL_MS; +} + +// ============================================================================ +// XML GENERATION UTILITIES +// ============================================================================ + +function escapeXml(unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function calculatePriority(entityType: string, metadata?: { ride_count?: number }): number { + switch (entityType) { + case 'home': return 1.0; + case 'park': + const rideCount = metadata?.ride_count || 0; + return rideCount > 10 ? 0.9 : 0.8; + case 'ride': return 0.9; + case 'manufacturer': return 0.7; + case 'model': return 0.6; + case 'designer': return 0.6; + case 'operator': return 0.6; + case 'owner': return 0.6; + case 'list': return 0.5; + case 'static': return 0.3; + default: return 0.5; + } +} + +function getChangeFreq(entityType: string): SitemapUrl['changefreq'] { + switch (entityType) { + case 'home': return 'daily'; + case 'park': + case 'ride': + case 'manufacturer': return 'weekly'; + case 'model': + case 'designer': + case 'operator': + case 'owner': return 'monthly'; + case 'list': return 'daily'; + case 'static': return 'yearly'; + default: return 'monthly'; + } +} + +function createUrl( + path: string, + entityType: string, + lastmod: string, + metadata?: { ride_count?: number } +): SitemapUrl { + return { + loc: `${BASE_URL}${path}`, + lastmod: new Date(lastmod).toISOString().split('T')[0], + changefreq: getChangeFreq(entityType), + priority: calculatePriority(entityType, metadata), + }; +} + +function generateSitemapXml(urls: SitemapUrl[]): string { + const header = '\n' + + '\n'; + + const entries = urls.map(url => + ` \n` + + ` ${escapeXml(url.loc)}\n` + + ` ${url.lastmod}\n` + + ` ${url.changefreq}\n` + + ` ${url.priority.toFixed(1)}\n` + + ` ` + ).join('\n'); + + const footer = '\n'; + + return header + entries + footer; +} + +// ============================================================================ +// SITEMAP GENERATION +// ============================================================================ + +async function generateSitemap(requestId: string): Promise<{ + xml: string; + stats: SitemapStats; +}> { + const startTime = Date.now(); + const urls: SitemapUrl[] = []; + const stats: SitemapStats = { + total_urls: 0, + parks: 0, + rides: 0, + manufacturers: 0, + models: 0, + designers: 0, + operators: 0, + owners: 0, + static_pages: 0, + generation_time_ms: 0, + }; + + const supabase = createClient( + Deno.env.get('SUPABASE_URL') ?? '', + Deno.env.get('SUPABASE_ANON_KEY') ?? '' + ); + + // Static pages + const now = new Date().toISOString(); + urls.push( + createUrl('/', 'home', now), + createUrl('/parks', 'list', now), + createUrl('/rides', 'list', now), + createUrl('/manufacturers', 'list', now), + createUrl('/designers', 'list', now), + createUrl('/operators', 'list', now), + createUrl('/owners', 'list', now), + createUrl('/blog', 'list', now), + createUrl('/terms', 'static', now), + createUrl('/privacy', 'static', now), + createUrl('/submission-guidelines', 'static', now), + createUrl('/contact', 'static', now), + ); + stats.static_pages = 12; + + // Parallel database queries + const [parksResult, ridesResult, companiesResult, modelsResult] = await Promise.all([ + supabase + .from('parks') + .select('slug, updated_at') + .not('slug', 'is', null) + .order('updated_at', { ascending: false }), + + supabase + .from('rides') + .select('slug, updated_at, park:parks!inner(slug)') + .not('slug', 'is', null) + .order('updated_at', { ascending: false }), + + supabase + .from('companies') + .select('slug, updated_at, company_type') + .not('slug', 'is', null) + .order('updated_at', { ascending: false }), + + supabase + .from('ride_models') + .select('slug, updated_at, manufacturer:companies!ride_models_manufacturer_id_fkey(slug)') + .not('slug', 'is', null) + .order('updated_at', { ascending: false }), + ]); + + // Process parks + if (parksResult.data) { + for (const park of parksResult.data) { + urls.push(createUrl(`/parks/${park.slug}`, 'park', park.updated_at)); + urls.push(createUrl(`/parks/${park.slug}/rides`, 'list', park.updated_at)); + stats.parks++; + } + } + + // Process rides + if (ridesResult.data) { + for (const ride of ridesResult.data) { + if (ride.park?.slug) { + urls.push(createUrl(`/parks/${ride.park.slug}/rides/${ride.slug}`, 'ride', ride.updated_at)); + stats.rides++; + } + } + } + + // Process companies + if (companiesResult.data) { + for (const company of companiesResult.data) { + const type = company.company_type; + + if (type === 'manufacturer') { + urls.push(createUrl(`/manufacturers/${company.slug}`, 'manufacturer', company.updated_at)); + urls.push(createUrl(`/manufacturers/${company.slug}/rides`, 'list', company.updated_at)); + urls.push(createUrl(`/manufacturers/${company.slug}/models`, 'list', company.updated_at)); + stats.manufacturers++; + } else if (type === 'designer') { + urls.push(createUrl(`/designers/${company.slug}`, 'designer', company.updated_at)); + urls.push(createUrl(`/designers/${company.slug}/rides`, 'list', company.updated_at)); + stats.designers++; + } else if (type === 'operator') { + urls.push(createUrl(`/operators/${company.slug}`, 'operator', company.updated_at)); + urls.push(createUrl(`/operators/${company.slug}/parks`, 'list', company.updated_at)); + stats.operators++; + } else if (type === 'property_owner') { + urls.push(createUrl(`/owners/${company.slug}`, 'owner', company.updated_at)); + urls.push(createUrl(`/owners/${company.slug}/parks`, 'list', company.updated_at)); + stats.owners++; + } + } + } + + // Process ride models + if (modelsResult.data) { + for (const model of modelsResult.data) { + if (model.manufacturer?.slug) { + urls.push( + createUrl(`/manufacturers/${model.manufacturer.slug}/models/${model.slug}`, 'model', model.updated_at) + ); + urls.push( + createUrl(`/manufacturers/${model.manufacturer.slug}/models/${model.slug}/rides`, 'list', model.updated_at) + ); + stats.models++; + } + } + } + + stats.total_urls = urls.length; + stats.generation_time_ms = Date.now() - startTime; + + const xml = generateSitemapXml(urls); + + console.log('[Sitemap] Generated', { + requestId, + stats, + sizeKB: (xml.length / 1024).toFixed(2), + }); + + return { xml, stats }; +} + +function generateFallbackSitemap(): string { + const now = new Date().toISOString(); + const urls: SitemapUrl[] = [ + createUrl('/', 'home', now), + createUrl('/parks', 'list', now), + createUrl('/rides', 'list', now), + createUrl('/manufacturers', 'list', now), + createUrl('/designers', 'list', now), + createUrl('/operators', 'list', now), + createUrl('/owners', 'list', now), + ]; + + return generateSitemapXml(urls); +} + +// ============================================================================ +// MAIN HANDLER +// ============================================================================ + +Deno.serve(async (req) => { + const requestId = crypto.randomUUID(); + const startTime = Date.now(); + + try { + // Return cached version if valid + if (isCacheValid()) { + const duration = Date.now() - startTime; + console.log('[Sitemap] Cache HIT', { + requestId, + cacheAge: Date.now() - cacheTimestamp, + duration, + }); + + return new Response(cachedSitemap, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'X-Request-ID': requestId, + 'X-Cache': 'HIT', + 'X-Generation-Time': `${duration}ms`, + ...cacheHeaders, + }, + }); + } + + // Generate fresh sitemap + const sitemap = await generateSitemap(requestId); + + // Update cache + cachedSitemap = sitemap.xml; + cacheTimestamp = Date.now(); + + const duration = Date.now() - startTime; + + console.log('[Sitemap] Cache MISS - Generated', { + requestId, + duration, + stats: sitemap.stats, + }); + + return new Response(sitemap.xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'X-Request-ID': requestId, + 'X-Cache': 'MISS', + 'X-Generation-Time': `${duration}ms`, + ...cacheHeaders, + }, + }); + + } catch (error) { + const duration = Date.now() - startTime; + + console.error('[Sitemap] Generation failed', { + requestId, + error: error instanceof Error ? error.message : String(error), + duration, + }); + + // Return minimal valid sitemap on error (graceful degradation) + const fallbackSitemap = generateFallbackSitemap(); + + return new Response(fallbackSitemap, { + status: 200, // Still return 200 for SEO + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'X-Request-ID': requestId, + 'X-Error': 'true', + 'Cache-Control': 'public, max-age=300', // Cache errors for 5min only + }, + }); + } +}); diff --git a/vercel.json b/vercel.json index 78a26866..3e3bab55 100644 --- a/vercel.json +++ b/vercel.json @@ -2,6 +2,10 @@ "installCommand": "bun install", "buildCommand": "bun run build", "rewrites": [ + { + "source": "/sitemap.xml", + "destination": "https://api.thrillwiki.com/functions/v1/sitemap" + }, { "source": "/", "destination": "/api/ssrOG"