// This is adapted from https://github.com/kentcdodds/kentcdodds.com import type { ServerBuild } from "@remix-run/server-runtime"; import isEqual from "lodash/isEqual"; import { SEOHandle, SitemapEntry } from "../types"; type Options = { siteUrl: string; }; function typedBoolean( value: T ): value is Exclude { return Boolean(value); } function removeTrailingSlash(s: string) { return s.endsWith("/") ? s.slice(0, -1) : s; } async function getSitemapXml( request: Request, routes: ServerBuild["routes"], options: Options ) { const { siteUrl } = options; function getEntry({ route, lastmod, changefreq, priority = 0.7, }: SitemapEntry) { return ` ${siteUrl}${route} ${lastmod ? `${lastmod}` : ""} ${changefreq ? `${changefreq}` : ""} ${typeof priority === "number" ? `${priority}` : ""} `.trim(); } const rawSitemapEntries = ( await Promise.all( Object.entries(routes).map(async ([id, { module: mod }]) => { if (id === "root") return; const handle = mod.handle as SEOHandle | undefined; if (handle?.getSitemapEntries) { return handle.getSitemapEntries(request); } // exclude resource routes from the sitemap // (these are an opt-in via the getSitemapEntries method) if (!("default" in mod)) return; const manifestEntry = routes[id]; if (!manifestEntry) { console.warn(`Could not find a manifest entry for ${id}`); return; } let parentId = manifestEntry.parentId; let parent = parentId ? routes[parentId] : null; let path; if (manifestEntry.path) { path = removeTrailingSlash(manifestEntry.path); } else if (manifestEntry.index) { path = ""; } else { return; } while (parent) { // the root path is '/', so it messes things up if we add another '/' const parentPath = parent.path ? removeTrailingSlash(parent.path) : ""; path = `${parentPath}/${path}`; parentId = parent.parentId; parent = parentId ? routes[parentId] : null; } // we can't handle dynamic routes, so if the handle doesn't have a // getSitemapEntries function, we just if (path.includes(":")) return; if (id === "root") return; const entry: SitemapEntry = { route: removeTrailingSlash(path) }; return entry; }) ) ) .flatMap((z) => z) .filter(typedBoolean); const sitemapEntries: Array = []; for (const entry of rawSitemapEntries) { const existingEntryForRoute = sitemapEntries.find( (e) => e.route === entry.route ); if (existingEntryForRoute) { if (!isEqual(existingEntryForRoute, entry)) { console.warn( `Duplicate route for ${entry.route} with different sitemap data`, { entry, existingEntryForRoute } ); } } else { sitemapEntries.push(entry); } } return ` ${sitemapEntries.map((entry) => getEntry(entry)).join("")} `.trim(); } export { getSitemapXml };