/** The /sitemap.xml document: the site's public, indexable URLs in one * sitemaps.org 0.9 urlset. It lists the home page, the fixed pages that * serve, every content page, and every news post. It also lists every * listing and group whose /ticket page serves. * * Every entry passes the same checks its public GET handler applies to a * buyer, so the document names only pages that answer 200. The document * carries no : no public page kind holds a timestamp that changes * when the page's content changes. The static path serves before the * request's settings load, so the handler reads its own keys, exactly like * /robots.txt. */ import { getNonStandaloneChildIds } from "#db/listing-parents.ts"; import { getAllListings } from "#db/listings/records.ts"; import { isMissingSettingsTableError } from "#db/migrations/errors.ts"; import { getNewsPostSummaries } from "#db/news-posts.ts"; import { settings } from "#db/settings.ts"; import { sitePages } from "#db/site-pages.ts"; import { loadDecidedGroups } from "#routes/public/group-liveness.ts"; import { loadPublicDomain, publicCacheResponse, } from "#routes/public-origin.ts"; import { isPublicListing } from "#shared/listing-visibility.ts"; import { SLUG_PARAM_SERVES } from "#shared/route-pattern.ts"; import { CONFIG_KEYS } from "#shared/settings/keys.ts"; import { escapeXml } from "#shared/xml.ts"; const SITEMAP_CONTENT_TYPE = "application/xml"; /** The settings keys the sitemap's reads need: the nav flags behind the fixed * pages, and LISTING_DEFAULTS behind the catalog's inherited hidden flags. * The static route serves before the request's settings load, so the handler * declares them itself, exactly like /robots.txt. */ const SITEMAP_SETTINGS_KEYS = [ CONFIG_KEYS.ENABLED_FEATURES, CONFIG_KEYS.BUSINESS_EMAIL, CONFIG_KEYS.CONTACT_FORM_ENABLED, CONFIG_KEYS.CONTACT_PAGE_TEXT, CONFIG_KEYS.LISTING_DEFAULTS, CONFIG_KEYS.ORDER_ENABLED, CONFIG_KEYS.TERMS_AND_CONDITIONS, ]; /** The fixed pages a visitor can load, with the check each public GET handler * applies. `/` and `/listings` serve behind the public-site gate the whole * sitemap sits behind. The rest answer 404 when their handler's content * check fails. Each is listed only while the check passes, and the flags * are the ones the public nav renders its links from. /news joins them * when the news list has a post to show. */ const fixedPublicPaths = ( flags: { hasContact: boolean; hasOrder: boolean; hasTerms: boolean }, hasNews: boolean, ): string[] => { const served: [string, boolean][] = [ ["/", true], ["/listings", true], ["/terms", flags.hasTerms], ["/contact", flags.hasContact], ["/order", flags.hasOrder], ["/news", hasNews], ]; return served.filter(([, loads]) => loads).map(([path]) => path); }; const urlEntry = (path: string, domain: string): string => { const loc = escapeXml(`https://${domain}${path}`); return ` ${loc}`; }; /** Every public URL the site serves, one per line. The slugs are stored * sealed, so the reads go through the entity caches that decrypt them. Each * catalog entry passes the checks its own page applies. A listing is * active, not hidden, and bookable on its own. A child that sells only * through its parent has no /ticket page. A group is not hidden and its * page serves. A package keeps its bookable bundle. A regular group keeps * one visible active member that is not a child. A content page is not * hidden. Every slug passes the * public route's own slug pattern, so a slug the router rejects — the * empty slug with it — stays out. */ const publicPaths = async (): Promise => { const [listings, posts, pages, groups] = await Promise.all([ getAllListings(), getNewsPostSummaries(), sitePages.getAll(), loadDecidedGroups("served"), ]); const nonStandaloneChildIds = await getNonStandaloneChildIds( listings.map((listing) => listing.id), ); // shared.tsx renders the public pages and must stay off the cold-start // path. The static sitemap route loads its nav flags only when it serves, // like every other public page defers it. const { navFlags } = await import("#templates/public/shared.tsx"); return [ ...fixedPublicPaths(navFlags(), posts.length > 0), ...pages .filter((page) => !page.hidden && SLUG_PARAM_SERVES.test(page.slug)) .map((page) => `/page/${page.slug}`), ...posts .filter((post) => SLUG_PARAM_SERVES.test(post.slug)) .map((post) => `/news/${post.slug}`), ...listings .filter( (listing) => isPublicListing(listing) && SLUG_PARAM_SERVES.test(listing.slug) && !nonStandaloneChildIds.has(listing.id), ) .map((listing) => `/ticket/${listing.slug}`), ...groups .filter(({ group }) => SLUG_PARAM_SERVES.test(group.slug)) .map(({ group }) => `/ticket/${group.slug}`), ]; }; export const handleSitemapXml = async (request: Request): Promise => { // A site before setup has no settings table, so the feature cannot be on // and the 404 is the answer. Every other failure stays loud. try { await settings.loadKeys(SITEMAP_SETTINGS_KEYS); } catch (error) { if (!isMissingSettingsTableError(error)) throw error; return new Response("Not found", { status: 404 }); } if (!settings.features.site) { return new Response("Not found", { status: 404 }); } const domain = await loadPublicDomain(new URL(request.url).hostname); const paths = await publicPaths(); const body = [ '', '', ...paths.map((path) => urlEntry(path, domain)), "", "", ].join("\n"); return publicCacheResponse(body, SITEMAP_CONTENT_TYPE); };