← back to Stayclaim

src/app/sitemap.ts

129 lines

import type { MetadataRoute } from 'next';
import { headers } from 'next/headers';
import { pool } from '@/lib/db';
import { siteForHost } from '@/lib/site';
import { FREE_FOREVER } from '@/lib/flags';

export const runtime = 'nodejs';
export const revalidate = 3600;

// Hard caps to prevent OOM at LA-scale and stay under Google's 50k/sitemap limit.
const SITEMAP_ROW_LIMIT = 45000;

function slugifyCity(city: string): string {
  return city
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

/**
 * sitemap.xml — host-aware: each domain (wholivedthere/claimmyaddress/bubbesblock)
 * gets a sitemap rooted at its own base URL. Editorial pages are public-indexable;
 * admin/owner/promo flows are excluded.
 */
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const h = await headers();
  const surface = siteForHost(h.get('x-pastdoor-host') || h.get('host'));
  // Always derive base from the resolved surface so each host gets its own canonical
  // sitemap. NEXT_PUBLIC_BASE_URL must NOT override per-host routing (would emit the
  // same URL on every domain → cross-domain duplicate-content).
  const base = `https://${surface.domain}`.replace(/\/$/, '');
  const now = new Date();

  // Static editorial pages — only real content. /hosts excluded when FREE_FOREVER.
  const staticUrls: MetadataRoute.Sitemap = [
    { url: `${base}/`, lastModified: now, changeFrequency: 'weekly', priority: 1.0 },
    { url: `${base}/about`, lastModified: now, changeFrequency: 'monthly', priority: 0.7 },
    { url: `${base}/people`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
    { url: `${base}/neighborhoods`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
    { url: `${base}/timeline`, lastModified: now, changeFrequency: 'daily', priority: 0.7 },
    { url: `${base}/architects`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
    { url: `${base}/map`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
    { url: `${base}/films`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
    { url: `${base}/locations-by-year`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
    ...(!FREE_FOREVER
      ? [{ url: `${base}/hosts`, lastModified: now, changeFrequency: 'monthly' as const, priority: 0.5 }]
      : []),
  ];

  // Address pages — only if they have at least one publicly-visible piece of evidence
  // (event OR association OR media). Pure stub pages stay out of the index.
  const { rows: addressRows } = await pool.query<{ slug: string; updated_at: Date; evidence_count: number }>(
    `SELECT l.slug, l.updated_at,
            (SELECT count(*) FROM place_event WHERE listing_id = l.id AND public_visible) +
            (SELECT count(*) FROM entity_place_association WHERE listing_id = l.id AND public_visible) +
            (SELECT count(*) FROM media_asset WHERE listing_id = l.id AND public_visible) AS evidence_count
     FROM listing l
     WHERE l.is_public = true AND l.slug IS NOT NULL
     LIMIT $1`,
    [SITEMAP_ROW_LIMIT]
  );
  const addressUrls: MetadataRoute.Sitemap = addressRows
    .filter(r => Number(r.evidence_count) > 0)
    .map(r => ({
      url: `${base}/address/${encodeURIComponent(r.slug)}`,
      lastModified: r.updated_at ?? now,
      changeFrequency: 'monthly',
      priority: 0.8,
    }));

  // Entity pages — only if they have at least one publicly-visible association.
  const { rows: entityRows } = await pool.query<{ slug: string; assoc_count: number }>(
    `SELECT e.slug,
            (SELECT count(*) FROM entity_place_association WHERE entity_id = e.id AND public_visible) AS assoc_count
     FROM entity e
     WHERE e.slug IS NOT NULL
     LIMIT $1`,
    [SITEMAP_ROW_LIMIT]
  );
  const entityUrls: MetadataRoute.Sitemap = entityRows
    .filter(r => Number(r.assoc_count) > 0)
    .map(r => ({
      url: `${base}/entity/${encodeURIComponent(r.slug)}`,
      lastModified: now,
      changeFrequency: 'monthly',
      priority: 0.6,
    }));

  // Neighborhood pages — only when ≥3 listings exist (avoid solitary stubs).
  const { rows: hoodRows } = await pool.query<{ city: string; count: number }>(
    `SELECT city, count(*)::int AS count FROM listing
     WHERE is_public = true AND city IS NOT NULL AND length(trim(city)) > 0
     GROUP BY city HAVING count(*) >= 3
     LIMIT $1`,
    [SITEMAP_ROW_LIMIT]
  );
  const hoodUrls: MetadataRoute.Sitemap = hoodRows
    .map(r => slugifyCity(r.city))
    .filter(slug => slug.length > 0)
    .map(slug => ({
      url: `${base}/neighborhood/${encodeURIComponent(slug)}`,
      lastModified: now,
      changeFrequency: 'weekly' as const,
      priority: 0.6,
    }));

  // Film productions — only those with at least one public location.
  const { rows: filmRows } = await pool.query<{ slug: string; loc_count: number }>(
    `SELECT fp.slug,
            (SELECT count(*) FROM filming_location WHERE production_id = fp.id AND public_visible) AS loc_count
     FROM film_production fp
     WHERE fp.slug IS NOT NULL
     LIMIT $1`,
    [SITEMAP_ROW_LIMIT]
  );
  const filmUrls: MetadataRoute.Sitemap = filmRows
    .filter(r => Number(r.loc_count) > 0)
    .map(r => ({
      url: `${base}/film/${encodeURIComponent(r.slug)}`,
      lastModified: now,
      changeFrequency: 'monthly' as const,
      priority: 0.6,
    }));

  return [...staticUrls, ...addressUrls, ...entityUrls, ...hoodUrls, ...filmUrls];
}