← 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];
}