import { queryCollection } from '@nuxt/content/server' import { getAvailableLocales, getCollectionsToQuery, isNavigationPath } from 'docus/server/utils/content' // Overrides Docus's own sitemap.xml route: theirs resolves the site URL via // `inferSiteURL()` (docus/utils/meta.ts), which only reads deployment-platform // env vars (Vercel/Netlify/Cloudflare Pages) or NUXT_PUBLIC_SITE_URL. On a // plain self-hosted build none of those are set, so it silently falls back to // an empty string and every ends up as a bare path instead of an // absolute URL, which is invalid per the sitemap spec. `createSitePathResolver` // builds each URL from the `site` config in nuxt.config.ts instead (the same // source canonical/og:url already use), including the `trailingSlash` setting, // so every URL in the sitemap stays consistent with those. interface SitemapUrl { loc: string lastmod?: string } export default defineEventHandler(async (event) => { const config = useRuntimeConfig(event) const resolveUrl = createSitePathResolver(event, { absolute: true }) const availableLocales = getAvailableLocales(config.public as Record) const collections = getCollectionsToQuery(undefined, availableLocales) if (availableLocales.length > 0) { for (const locale of availableLocales) { collections.push(`landing_${locale}`) } } else { collections.push('landing') } const urls: SitemapUrl[] = [] for (const collection of collections) { try { const pages = await (queryCollection as unknown as ( event: unknown, collection: string, ) => { all: () => Promise & { path?: string }>> })(event, collection).all() for (const page of pages) { const meta = page.meta as Record const pagePath = page.path || '/' // Skip pages with sitemap: false in frontmatter if (meta.sitemap === false) continue // Skip .navigation files (used for navigation configuration) if (isNavigationPath(pagePath)) continue const urlEntry: SitemapUrl = { loc: pagePath, } // Add lastmod if available (modifiedAt from content) if (meta.modifiedAt && typeof meta.modifiedAt === 'string') { urlEntry.lastmod = meta.modifiedAt.split('T')[0] // Use date part only (YYYY-MM-DD) } urls.push(urlEntry) } } catch { // Collection might not exist, skip it } } const sitemap = generateSitemap(urls, resolveUrl) setResponseHeader(event, 'content-type', 'application/xml') return sitemap }) function generateSitemap(urls: SitemapUrl[], resolveUrl: (path: string) => string): string { const urlEntries = urls .map((url) => { const loc = resolveUrl(url.loc) let entry = ` \n ${escapeXml(loc)}` if (url.lastmod) { entry += `\n ${escapeXml(url.lastmod)}` } entry += `\n ` return entry }) .join('\n') return ` ${urlEntries} ` } function escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') }