UNPKG

nuxt-simple-sitemap

Version:

Powerfully flexible XML Sitemaps that integrate seamlessly, for Nuxt.

989 lines (976 loc) 39 kB
import { useNuxt, loadNuxtModuleInstance, createResolver, addTemplate, extendPages, defineNuxtModule, useLogger, hasNuxtModule, getNuxtModuleVersion, hasNuxtModuleCompatibility, addServerImports, addServerPlugin, addServerHandler, findPath, addPrerenderRoutes } from '@nuxt/kit'; import { parseURL, withLeadingSlash, withBase, joinURL, withoutLeadingSlash } from 'ufo'; import { assertSiteConfig, installNuxtSiteConfig } from 'nuxt-site-config-kit'; import { defu, createDefu } from 'defu'; import { statSync, existsSync } from 'node:fs'; import { extname, relative, dirname } from 'pathe'; import { provider, env } from 'std-env'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import chalk from 'chalk'; import { build } from 'nitropack'; import { withSiteUrl } from 'nuxt-site-config-kit/urls'; import 'site-config-stack/urls'; const version = "4.4.0"; async function resolveUrls(urls) { if (typeof urls === "function") urls = urls(); urls = await urls; return urls; } function deepForEachPage(pages, callback, fullpath = null, depth = 0) { pages.forEach((page) => { let currentPath; if (page.path.startsWith("/")) currentPath = page.path; else currentPath = page.path === "" ? fullpath : `${fullpath.replace(/\/$/, "")}/${page.path}`; callback(page, currentPath || "", depth); if (page.children) deepForEachPage(page.children, callback, currentPath, depth + 1); }); } function convertNuxtPagesToSitemapEntries(pages, config) { const routesNameSeparator = config.routesNameSeparator || "___"; let flattenedPages = []; deepForEachPage( pages, (page, loc, depth) => { flattenedPages.push({ page, loc, depth }); } ); flattenedPages = flattenedPages.filter((page) => !page.loc.includes(":")).filter((page, idx, arr) => { return !arr.find((p) => { return p.loc === page.loc && p.depth > page.depth; }); }).map((p) => { delete p.depth; return p; }); const pagesWithMeta = flattenedPages.map((p) => { if (config.autoLastmod && p.page.file) { try { const stats = statSync(p.page.file); if (stats?.mtime) p.lastmod = stats.mtime; } catch (e) { } } if (p.page?.meta?.sitemap) { p = defu(p.page.meta.sitemap, p); } return p; }); const localeGroups = {}; pagesWithMeta.reduce((acc, e) => { if (e.page.name?.includes(routesNameSeparator)) { const [name, locale] = e.page.name.split(routesNameSeparator); if (!acc[name]) acc[name] = []; const { iso, code } = config.normalisedLocales.find((l) => l.code === locale) || { iso: locale, code: locale }; acc[name].push({ ...e, _sitemap: config.isI18nMapped ? iso || code : void 0, locale }); } else { acc.default = acc.default || []; acc.default.push(e); } return acc; }, localeGroups); return Object.entries(localeGroups).map(([locale, entries]) => { if (locale === "default") { return entries.map((e) => { const [name] = (e.page?.name || "").split(routesNameSeparator); if (localeGroups[name]?.some((a) => a.locale === config.defaultLocale)) return false; const defaultLocale = config.normalisedLocales.find((l) => l.code === config.defaultLocale); if (defaultLocale && config.isI18nMapped) e._sitemap = defaultLocale.iso || defaultLocale.code; delete e.page; delete e.locale; return { ...e }; }).filter(Boolean); } return entries.map((entry) => { const alternatives = entries.map((entry2) => { const hreflang = config.normalisedLocales.find((l) => l.code === entry2.locale)?.iso || entry2.locale; return { hreflang, href: entry2.loc }; }); const xDefault = entries.find((a) => a.locale === config.defaultLocale); if (xDefault && alternatives.length) { alternatives.push({ hreflang: "x-default", href: xDefault.loc }); } const e = { ...entry }; if (config.isI18nMapped) { const { iso, code } = config.normalisedLocales.find((l) => l.code === entry.locale) || { iso: locale, code: locale }; e._sitemap = iso || code; } delete e.page; delete e.locale; return { ...e, alternatives }; }); }).filter(Boolean).flat(); } function generateExtraRoutesFromNuxtConfig(nuxt = useNuxt()) { const routeRules = Object.entries(nuxt.options.routeRules || {}).filter(([k, v]) => { if (k.includes("*") || k.includes(".") || k.includes(":")) return false; if (typeof v.index === "boolean" && !v.index) return false; return !v.redirect; }).map(([k]) => k); const prerenderUrls = (nuxt.options.nitro.prerender?.routes || []).filter((p) => p && !extname(p) && !p.startsWith("/api/")); return { routeRules, prerenderUrls }; } async function getNuxtModuleOptions(module, nuxt = useNuxt()) { const moduleMeta = (typeof module === "string" ? { name: module } : await module.getMeta?.()) || {}; const { nuxtModule } = await loadNuxtModuleInstance(module, nuxt); let moduleEntry; for (const m of nuxt.options.modules) { if (Array.isArray(m) && m.length >= 2) { const _module = m[0]; const _moduleEntryName = typeof _module === "string" ? _module : (await _module.getMeta?.())?.name || ""; if (_moduleEntryName === moduleMeta.name) moduleEntry = m; } } let inlineOptions = {}; if (moduleEntry) inlineOptions = moduleEntry[1]; if (nuxtModule.getOptions) return nuxtModule.getOptions(inlineOptions, nuxt); return inlineOptions; } function extendTypes(module, template) { const nuxt = useNuxt(); const { resolve } = createResolver(import.meta.url); addTemplate({ filename: `module/${module}.d.ts`, getContents: async () => { const typesPath = relative(resolve(nuxt.options.rootDir, nuxt.options.buildDir, "module"), resolve("runtime/types")); const s = await template({ typesPath }); return `// Generated by ${module} ${s} export {} `; } }); nuxt.hooks.hook("prepare:types", ({ references }) => { references.push({ path: resolve(nuxt.options.buildDir, `module/${module}.d.ts`) }); }); } function createPagesPromise(nuxt = useNuxt()) { return new Promise((resolve) => { nuxt.hooks.hook("modules:done", () => { extendPages(resolve); }); }); } function createNitroPromise(nuxt = useNuxt()) { return new Promise((resolve) => { nuxt.hooks.hook("nitro:init", (nitro) => { resolve(nitro); }); }); } const autodetectableProviders = { azure_static: "azure", cloudflare_pages: "cloudflare-pages", netlify: "netlify", stormkit: "stormkit", vercel: "vercel", cleavr: "cleavr", stackblitz: "stackblitz" }; const autodetectableStaticProviders = { netlify: "netlify-static", vercel: "vercel-static" }; function detectTarget(options = {}) { return options?.static ? autodetectableStaticProviders[provider] : autodetectableProviders[provider]; } function resolveNitroPreset(nitroConfig) { if (provider === "stackblitz") return "stackblitz"; let preset; if (nitroConfig && nitroConfig?.preset) preset = nitroConfig.preset; if (!preset) preset = env.NITRO_PRESET || detectTarget() || "node-server"; return preset.replace("_", "-"); } function extractSitemapMetaFromHtml(html, options) { options = options || { images: true, lastmod: true, alternatives: true }; const payload = {}; if (options?.images) { const images = /* @__PURE__ */ new Set(); const mainRegex = /<main[^>]*>([\s\S]*?)<\/main>/; const mainMatch = mainRegex.exec(html); if (mainMatch?.[1] && mainMatch[1].includes("<img")) { const imgRegex = /<img[^>]+src="([^">]+)"/g; let match; while ((match = imgRegex.exec(mainMatch[1])) !== null) { if (match.index === imgRegex.lastIndex) imgRegex.lastIndex++; let url = match[1]; if (url.startsWith("/")) url = withSiteUrl(url); images.add(url); } } if (images.size > 0) payload.images = [...images].map((i) => ({ loc: i })); } if (options?.lastmod) { const articleModifiedTime = html.match(/<meta[^>]+property="article:modified_time"[^>]+content="([^"]+)"/)?.[1] || html.match(/<meta[^>]+content="([^"]+)"[^>]+property="article:modified_time"/)?.[1]; if (articleModifiedTime) payload.lastmod = articleModifiedTime; } if (options?.alternatives) { const alternatives = (html.match(/<link[^>]+rel="alternate"[^>]+>/g) || []).map((a) => { const href = a.match(/href="([^"]+)"/)?.[1]; const hreflang = a.match(/hreflang="([^"]+)"/)?.[1]; return { hreflang, href: parseURL(href).pathname }; }).filter((a) => a.hreflang && a.href); if (alternatives?.length && (alternatives.length > 1 || alternatives?.[0].hreflang !== "x-default")) payload.alternatives = alternatives; } return payload; } const merger = createDefu((obj, key, value) => { if (Array.isArray(obj[key]) && Array.isArray(value)) obj[key] = Array.from(/* @__PURE__ */ new Set([...obj[key], ...value])); return obj[key]; }); function mergeOnKey(arr, key) { const res = {}; arr.forEach((item) => { const k = item[key]; res[k] = merger(item, res[k] || {}); }); return Object.values(res); } function splitForLocales(path, locales) { const prefix = withLeadingSlash(path).split("/")[1]; if (locales.includes(prefix)) return [prefix, path.replace(`/${prefix}`, "")]; return [null, path]; } function formatPrerenderRoute(route) { let str = ` \u251C\u2500 ${route.route} (${route.generateTimeMS}ms)`; if (route.error) { const errorColor = chalk[route.error.statusCode === 404 ? "yellow" : "red"]; const errorLead = "\u2514\u2500\u2500"; str += ` \u2502 ${errorLead} ${errorColor(route.error)}`; } return chalk.gray(str); } function includesSitemapRoot(sitemapName, routes) { return routes.includes(`/sitemap.xml`) || routes.includes(`/${sitemapName}`) || routes.includes("/sitemap_index.xml"); } function isNuxtGenerate(nuxt = useNuxt()) { return nuxt.options._generate || nuxt.options.nitro.static || nuxt.options.nitro.preset === "static"; } function setupPrerenderHandler(options, nuxt = useNuxt()) { const prerenderedRoutes = nuxt.options.nitro.prerender?.routes || []; const prerenderSitemap = isNuxtGenerate() || includesSitemapRoot(options.sitemapName, prerenderedRoutes); if (nuxt.options.nitro.prerender?.routes) nuxt.options.nitro.prerender.routes = nuxt.options.nitro.prerender.routes.filter((r) => r && !includesSitemapRoot(options.sitemapName, [r])); nuxt.hooks.hook("nitro:init", async (nitro) => { let prerenderer; nitro.hooks.hook("prerender:init", async (_prerenderer) => { prerenderer = _prerenderer; assertSiteConfig("nuxt-simple-sitemap", { url: "Required to generate absolute canonical URLs for your sitemap." }, { throwError: false }); }); nitro.hooks.hook("prerender:generate", async (route) => { const html = route.contents; if (!route.fileName?.endsWith(".html") || !html) return; route._sitemap = defu(route._sitemap, { loc: route.route }); if (options.autoI18n && Object.keys(options.sitemaps).length > 1) { const path = route.route; const match = splitForLocales(path, options.autoI18n.locales.map((l) => l.code)); const locale = match[0] || options.autoI18n.defaultLocale; if (options.isI18nMapped) { const { code, iso } = options.autoI18n.locales.find((l) => l.code === locale) || { code: locale, iso: locale }; route._sitemap._sitemap = iso || code; } } route._sitemap = defu(extractSitemapMetaFromHtml(html, { images: options.discoverImages, // TODO configurable? lastmod: true, alternatives: true }), route._sitemap); }); nitro.hooks.hook("prerender:done", async () => { await build(prerenderer); const routes = []; if (options.debug) routes.push("/__sitemap__/debug.json"); if (prerenderSitemap) { routes.push( options.isMultiSitemap ? "/sitemap_index.xml" : `/${Object.keys(options.sitemaps)[0]}` ); } for (const route of routes) await prerenderRoute(nitro, route); }); }); } async function prerenderRoute(nitro, route) { const start = Date.now(); const _route = { route, fileName: route }; const encodedRoute = encodeURI(route); const res = await globalThis.$fetch.raw( withBase(encodedRoute, nitro.options.baseURL), { headers: { "x-nitro-prerender": encodedRoute }, retry: nitro.options.prerender.retry, retryDelay: nitro.options.prerender.retryDelay } ); const header = res.headers.get("x-nitro-prerender") || ""; const prerenderUrls = [ ...header.split(",").map((i) => i.trim()).map((i) => decodeURIComponent(i)).filter(Boolean) ]; const filePath = join(nitro.options.output.publicDir, _route.fileName); await mkdir(dirname(filePath), { recursive: true }); const data = res._data; if (filePath.endsWith("json") || typeof data === "object") await writeFile(filePath, JSON.stringify(data), "utf8"); else await writeFile(filePath, data, "utf8"); _route.generateTimeMS = Date.now() - start; nitro._prerenderedRoutes.push(_route); nitro.logger.log(formatPrerenderRoute(_route)); for (const url of prerenderUrls) await prerenderRoute(nitro, url); } const DEVTOOLS_UI_ROUTE = "/__nuxt-simple-sitemap"; const DEVTOOLS_UI_LOCAL_PORT = 3030; function setupDevToolsUI(options, resolve, nuxt = useNuxt()) { const clientPath = resolve("./client"); const isProductionBuild = existsSync(clientPath); if (isProductionBuild) { nuxt.hook("vite:serverCreated", async (server) => { const sirv = await import('sirv').then((r) => r.default || r); server.middlewares.use( DEVTOOLS_UI_ROUTE, sirv(clientPath, { dev: true, single: true }) ); }); } else { nuxt.hook("vite:extendConfig", (config) => { config.server = config.server || {}; config.server.proxy = config.server.proxy || {}; config.server.proxy[DEVTOOLS_UI_ROUTE] = { target: `http://localhost:${DEVTOOLS_UI_LOCAL_PORT}${DEVTOOLS_UI_ROUTE}`, changeOrigin: true, followRedirects: true, rewrite: (path) => path.replace(DEVTOOLS_UI_ROUTE, "") }; }); } nuxt.hook("devtools:customTabs", (tabs) => { tabs.push({ // unique identifier name: "nuxt-simple-sitemap", // title to display in the tab title: "Sitemap", // any icon from Iconify, or a URL to an image icon: "carbon:load-balancer-application", // iframe view view: { type: "iframe", src: DEVTOOLS_UI_ROUTE } }); }); } function normaliseDate(d) { if (typeof d === "string") { d = d.replace("Z", ""); d = d.replace(/\.\d+$/, ""); if (d.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/) || d.match(/^\d{4}-\d{2}-\d{2}$/)) return d; d = new Date(d); if (Number.isNaN(d.getTime())) return false; } const z = (n) => `0${n}`.slice(-2); return `${d.getUTCFullYear()}-${z(d.getUTCMonth() + 1)}-${z(d.getUTCDate())}T${z(d.getUTCHours())}:${z(d.getUTCMinutes())}:${z(d.getUTCSeconds())}+00:00`; } function splitPathForI18nLocales(path, autoI18n) { const locales = autoI18n.strategy === "prefix_except_default" ? autoI18n.locales.filter((l) => l.code !== autoI18n.defaultLocale) : autoI18n.locales; if (typeof path !== "string" || path.startsWith("/api") || path.startsWith("/_nuxt")) return path; const match = splitForLocales(path, locales.map((l) => l.code)); const locale = match[0]; if (locale) return path; return [ path, ...locales.map((l) => `/${l.code}${path}`) ]; } function generatePathForI18nPages({ localeCode, pageLocales, nuxtI18nConfig, forcedStrategy }) { switch (forcedStrategy ?? nuxtI18nConfig.strategy) { case "prefix_except_default": case "prefix_and_default": return localeCode === nuxtI18nConfig.defaultLocale ? pageLocales : joinURL(localeCode, pageLocales); case "prefix": return joinURL(localeCode, pageLocales); case "no_prefix": default: return pageLocales; } } function isValidFilter(filter) { if (typeof filter === "string") return true; if (filter instanceof RegExp) return true; if (typeof filter === "object" && typeof filter.regex === "string") return true; return false; } function normalizeFilters(filters) { return (filters || []).map((filter) => { if (!isValidFilter(filter)) { console.warn(`[Nuxt Simple Sitemap] You have provided an invalid filter: ${filter}, ignoring.`); return false; } return filter instanceof RegExp ? { regex: filter.toString() } : filter; }).filter(Boolean); } const module = defineNuxtModule({ meta: { name: "nuxt-simple-sitemap", compatibility: { nuxt: "^3.9.0", bridge: false }, configKey: "sitemap" }, defaults: { enabled: true, credits: true, cacheMaxAgeSeconds: 60 * 10, // cache for 10 minutes debug: false, defaultSitemapsChunkSize: 1e3, autoLastmod: false, discoverImages: true, dynamicUrlsApiEndpoint: "/api/_sitemap-urls", urls: [], sortEntries: true, xsl: "/__sitemap__/style.xsl", xslTips: true, strictNuxtContentPaths: false, runtimeCacheStorage: true, sitemapName: "sitemap.xml", // cacheControlHeader: 'max-age=600, must-revalidate', defaults: {}, // index sitemap options filtering include: [], exclude: ["/_nuxt/**", "/api/**"], // sources sources: [], excludeAppSources: [], inferStaticPagesAsRoutes: true }, async setup(config, nuxt) { const logger = useLogger("nuxt-simple-sitemap"); logger.level = config.debug || nuxt.options.debug ? 4 : 3; if (config.enabled === false) { logger.debug("The module is disabled, skipping setup."); return; } config.xslColumns = config.xslColumns || [ { label: "URL", width: "50%" }, { label: "Images", width: "25%", select: "count(image:image)" }, { label: "Last Updated", width: "25%", select: "concat(substring(sitemap:lastmod,0,11),concat(' ', substring(sitemap:lastmod,12,5)),concat(' ', substring(sitemap:lastmod,20,6)))" } ]; if (config.autoLastmod) { config.defaults = config.defaults || {}; config.defaults.lastmod = normaliseDate(/* @__PURE__ */ new Date()); } const { resolve } = createResolver(import.meta.url); await installNuxtSiteConfig(); const userGlobalSources = [ ...config.sources || [] ]; const appGlobalSources = []; nuxt.options.nitro.storage = nuxt.options.nitro.storage || {}; if (config.runtimeCacheStorage && !nuxt.options.dev && typeof config.runtimeCacheStorage === "object") nuxt.options.nitro.storage["nuxt-simple-sitemap"] = config.runtimeCacheStorage; if (!config.sitemapName.endsWith("xml")) { const newName = `${config.sitemapName.split(".")[0]}.xml`; logger.warn(`You have provided a \`sitemapName\` that does not end with \`.xml\`. This is not supported by search engines, renaming to \`${newName}\`.`); config.sitemapName = newName; } config.sitemapName = withoutLeadingSlash(config.sitemapName); let usingMultiSitemaps = !!config.sitemaps; let isI18nMapped = false; let nuxtI18nConfig = {}; let resolvedAutoI18n = typeof config.autoI18n === "boolean" ? false : config.autoI18n || false; const hasDisabledAutoI18n = typeof config.autoI18n === "boolean" && !config.autoI18n; let normalisedLocales = []; if (hasNuxtModule("@nuxtjs/i18n")) { const i18nVersion = await getNuxtModuleVersion("@nuxtjs/i18n"); if (!await hasNuxtModuleCompatibility("@nuxtjs/i18n", ">=8")) logger.warn(`You are using @nuxtjs/i18n v${i18nVersion}. For the best compatibility, please upgrade to @nuxtjs/i18n v8.0.0 or higher.`); nuxtI18nConfig = await getNuxtModuleOptions("@nuxtjs/i18n") || {}; normalisedLocales = mergeOnKey((nuxtI18nConfig.locales || []).map((locale) => typeof locale === "string" ? { code: locale } : locale), "code"); const usingI18nPages = Object.keys(nuxtI18nConfig.pages || {}).length; if (usingI18nPages && !hasDisabledAutoI18n) { const i18nPagesSources = { context: { name: "@nuxtjs/i18n:pages", description: "Generated from your i18n.pages config.", tips: [ "You can disable this with `autoI18n: false`." ] }, urls: [] }; for (const pageLocales of Object.values(nuxtI18nConfig?.pages)) { for (const localeCode in pageLocales) { const locale = normalisedLocales.find((l) => l.code === localeCode); if (!locale || !pageLocales[localeCode] || pageLocales[localeCode].includes("[")) continue; const alternatives = Object.keys(pageLocales).map((l) => ({ hreflang: normalisedLocales.find((nl) => nl.code === l)?.iso || l, href: generatePathForI18nPages({ localeCode: l, pageLocales: pageLocales[l], nuxtI18nConfig }) })); if (alternatives.length && nuxtI18nConfig.defaultLocale && pageLocales[nuxtI18nConfig.defaultLocale]) alternatives.push({ hreflang: "x-default", href: generatePathForI18nPages({ localeCode: nuxtI18nConfig.defaultLocale, pageLocales: pageLocales[nuxtI18nConfig.defaultLocale], nuxtI18nConfig }) }); i18nPagesSources.urls.push({ _sitemap: locale.iso || locale.code, loc: generatePathForI18nPages({ localeCode, pageLocales: pageLocales[localeCode], nuxtI18nConfig }), alternatives }); if (nuxtI18nConfig.strategy === "prefix_and_default" && localeCode === nuxtI18nConfig.defaultLocale) { i18nPagesSources.urls.push({ _sitemap: locale.iso || locale.code, loc: generatePathForI18nPages({ localeCode, pageLocales: pageLocales[localeCode], nuxtI18nConfig, forcedStrategy: "prefix" }), alternatives }); } } } appGlobalSources.push(i18nPagesSources); if (Array.isArray(config.excludeAppSources)) config.excludeAppSources.push("nuxt:pages"); } else { if (!normalisedLocales.length) logger.warn("You are using @nuxtjs/i18n but have not configured any locales, this will cause issues with nuxt-simple-sitemap. Please configure `locales`."); } const hasSetAutoI18n = typeof config.autoI18n === "object" && Object.keys(config.autoI18n).length; const hasI18nConfigForAlternatives = nuxtI18nConfig.differentDomains || usingI18nPages || nuxtI18nConfig.strategy !== "no_prefix" && nuxtI18nConfig.locales; if (!hasSetAutoI18n && !hasDisabledAutoI18n && hasI18nConfigForAlternatives) { resolvedAutoI18n = { differentDomains: nuxtI18nConfig.differentDomains, defaultLocale: nuxtI18nConfig.defaultLocale, locales: normalisedLocales, strategy: nuxtI18nConfig.strategy }; } if (typeof config.sitemaps === "undefined" && !!resolvedAutoI18n && nuxtI18nConfig.strategy !== "no_prefix") { config.sitemaps = { index: [] }; for (const locale of resolvedAutoI18n.locales) { config.sitemaps[locale.iso || locale.code] = { includeAppSources: true }; } isI18nMapped = true; usingMultiSitemaps = true; } } let needsRobotsPolyfill = true; if (hasNuxtModule("nuxt-simple-robots")) { const robotsVersion = await getNuxtModuleVersion("nuxt-simple-robots"); if (!await hasNuxtModuleCompatibility("nuxt-simple-robots", ">=4")) logger.warn(`You are using nuxt-simple-robots v${robotsVersion}. For the best compatibility, please upgrade to nuxt-simple-robots v4.0.0 or higher.`); else needsRobotsPolyfill = false; nuxt.hooks.hook("robots:config", (robotsConfig) => { robotsConfig.sitemap.push(usingMultiSitemaps ? "/sitemap_index.xml" : `/${config.sitemapName}`); }); } if (needsRobotsPolyfill) { addServerImports([{ name: "getPathRobotConfigPolyfill", as: "getPathRobotConfig", from: resolve("./runtime/nitro/composables/getPathRobotConfigPolyfill") }]); } extendTypes("nuxt-simple-sitemap", async ({ typesPath }) => { return ` declare module 'nitropack' { interface NitroRouteRules { index?: boolean sitemap?: import('${typesPath}').SitemapItemDefaults } interface NitroRouteConfig { index?: boolean sitemap?: import('${typesPath}').SitemapItemDefaults } interface NitroRuntimeHooks { 'sitemap:resolved': (ctx: import('${typesPath}').SitemapRenderCtx) => void | Promise<void> 'sitemap:output': (ctx: import('${typesPath}').SitemapOutputHookCtx) => void | Promise<void> } } declare module 'vue-router' { interface RouteMeta { sitemap?: import('${typesPath}').SitemapItemDefaults } } `; }); const nitroPreset = resolveNitroPreset(); const prerenderedRoutes = nuxt.options.nitro.prerender?.routes || []; const prerenderSitemap = isNuxtGenerate() || includesSitemapRoot(config.sitemapName, prerenderedRoutes); const routeRules = {}; nuxt.options.nitro.routeRules = nuxt.options.nitro.routeRules || {}; if (prerenderSitemap) { routeRules.headers = { "Content-Type": "text/xml; charset=UTF-8" }; } if (!nuxt.options.dev && !isNuxtGenerate() && config.cacheMaxAgeSeconds && config.runtimeCacheStorage !== false) { routeRules[nitroPreset.includes("vercel") ? "isr" : "swr"] = config.cacheMaxAgeSeconds; routeRules.cache = { // handle multi-tenancy varies: ["X-Forwarded-Host", "X-Forwarded-Proto", "Host"] }; if (typeof config.runtimeCacheStorage === "object") routeRules.cache.base = "nuxt-simple-sitemap"; } nuxt.options.nitro.routeRules["/sitemap.xsl"] = { headers: { "Content-Type": "application/xslt+xml" } }; if (usingMultiSitemaps) { nuxt.options.nitro.routeRules["/sitemap_index.xml"] = routeRules; if (typeof config.sitemaps === "object") { for (const k in config.sitemaps) nuxt.options.nitro.routeRules[`/${k}-sitemap.xml`] = routeRules; } else { nuxt.options.nitro.routeRules[`/${config.sitemapName}`] = routeRules; } } else { nuxt.options.nitro.routeRules[`/${config.sitemapName}`] = routeRules; } if (config.experimentalWarmUp) addServerPlugin(resolve("./runtime/nitro/plugins/warm-up")); if (config.experimentalCompression) addServerPlugin(resolve("./runtime/nitro/plugins/compression")); const isNuxtContentDocumentDriven = !!nuxt.options.content?.documentDriven || config.strictNuxtContentPaths; if (hasNuxtModule("@nuxt/content")) { addServerPlugin(resolve("./runtime/nitro/plugins/nuxt-content")); addServerHandler({ route: "/__sitemap__/nuxt-content-urls.json", handler: resolve("./runtime/routes/__sitemap__/nuxt-content-urls") }); const tips = []; if (nuxt.options.content?.documentDriven) tips.push("Enabled because you're using `@nuxt/content` with `documentDriven: true`."); else if (config.strictNuxtContentPaths) tips.push("Enabled because you've set `config.strictNuxtContentPaths: true`."); else tips.push("You can provide a `sitemap` key in your markdown frontmatter to configure specific URLs. Make sure you include a `loc`."); appGlobalSources.push({ context: { name: "@nuxt/content:urls", description: "Generated from your markdown files.", tips }, fetch: "/__sitemap__/nuxt-content-urls.json" }); } const hasLegacyDefaultApiSource = !!await findPath(resolve(nuxt.options.serverDir, "api/_sitemap-urls")); if ( // make sure they didn't manually add it as a source !config.sources?.includes("/api/_sitemap-urls") && (hasLegacyDefaultApiSource || config.dynamicUrlsApiEndpoint !== "/api/_sitemap-urls") ) { userGlobalSources.push({ context: { name: "dynamicUrlsApiEndpoint", description: "Generated from your dynamicUrlsApiEndpoint config.", tips: [ "The `dynamicUrlsApiEndpoint` config is deprecated.", hasLegacyDefaultApiSource ? "Consider renaming the `api/_sitemap-urls` file and add it the `sitemap.sources` config instead. This provides more explicit sitemap generation." : "Consider switching to using the `sitemap.sources` config which also supports fetch options." ] }, fetch: hasLegacyDefaultApiSource ? "/api/_sitemap-urls" : config.dynamicUrlsApiEndpoint }); } else { config.dynamicUrlsApiEndpoint = false; } const sitemaps = {}; if (usingMultiSitemaps) { addServerHandler({ route: "/sitemap_index.xml", handler: resolve("./runtime/routes/sitemap_index.xml") }); sitemaps.index = { sitemapName: "index", _route: withBase("sitemap_index.xml", nuxt.options.app.baseURL || "/"), // TODO better index support // @ts-expect-error untyped sitemaps: config.sitemaps.index || [] }; if (typeof config.sitemaps === "object") { for (const sitemapName in config.sitemaps) { if (sitemapName === "index") continue; addServerHandler({ route: `/${sitemapName}-sitemap.xml`, handler: resolve("./runtime/middleware/[sitemap]-sitemap.xml") }); const definition = config.sitemaps[sitemapName]; sitemaps[sitemapName] = defu( { sitemapName, _route: withBase(`${sitemapName}-sitemap.xml`, nuxt.options.app.baseURL || "/"), _hasSourceChunk: typeof definition.urls !== "undefined" || definition.sources?.length || !!definition.dynamicUrlsApiEndpoint }, { ...definition, urls: void 0, sources: void 0 }, { include: config.include, exclude: config.exclude } ); } } else { addServerHandler({ handler: resolve("./runtime/middleware/[sitemap]-sitemap.xml") }); sitemaps.chunks = { sitemapName: "chunks", defaults: config.defaults, include: config.include, exclude: config.exclude, includeAppSources: true }; } } else { sitemaps[config.sitemapName] = { sitemapName: config.sitemapName, route: withBase(config.sitemapName, nuxt.options.app.baseURL || "/"), // will contain the xml defaults: config.defaults, include: config.include, exclude: config.exclude, includeAppSources: true }; } if (resolvedAutoI18n && resolvedAutoI18n.locales && resolvedAutoI18n.strategy !== "no_prefix") { const i18n = resolvedAutoI18n; for (const sitemapName in sitemaps) { if (["index", "chunks"].includes(sitemapName)) continue; const sitemap = sitemaps[sitemapName]; sitemap.include = (sitemap.include || []).map((path) => splitPathForI18nLocales(path, i18n)).flat(); sitemap.exclude = (sitemap.exclude || []).map((path) => splitPathForI18nLocales(path, i18n)).flat(); } } for (const sitemapName in sitemaps) { const sitemap = sitemaps[sitemapName]; sitemap.include = normalizeFilters(sitemap.include); sitemap.exclude = normalizeFilters(sitemap.exclude); } const runtimeConfig = { isI18nMapped, sitemapName: config.sitemapName, isMultiSitemap: usingMultiSitemaps, excludeAppSources: config.excludeAppSources, autoLastmod: config.autoLastmod, defaultSitemapsChunkSize: config.defaultSitemapsChunkSize, sortEntries: config.sortEntries, debug: config.debug, // needed for nuxt/content integration and prerendering discoverImages: config.discoverImages, /* @nuxt/content */ isNuxtContentDocumentDriven, /* xsl styling */ xsl: config.xsl, xslTips: config.xslTips, xslColumns: config.xslColumns, credits: config.credits, version, sitemaps }; if (resolvedAutoI18n) runtimeConfig.autoI18n = resolvedAutoI18n; nuxt.options.runtimeConfig["nuxt-simple-sitemap"] = runtimeConfig; if (config.debug || nuxt.options.dev) { addServerHandler({ route: "/__sitemap__/debug.json", handler: resolve("./runtime/routes/__sitemap__/debug") }); setupDevToolsUI(config, resolve); } if (!config.inferStaticPagesAsRoutes) config.excludeAppSources = true; const imports = [ { from: resolve("./runtime/composables/defineSitemapEventHandler"), name: "defineSitemapEventHandler" }, { from: resolve("./runtime/composables/asSitemapUrl"), name: "asSitemapUrl" } ]; addServerImports(imports); const pagesPromise = createPagesPromise(); const nitroPromise = createNitroPromise(); let resolvedConfigUrls = false; nuxt.hooks.hook("nitro:config", (nitroConfig) => { nitroConfig.virtual["#nuxt-simple-sitemap/global-sources.mjs"] = async () => { const { prerenderUrls, routeRules: routeRules2 } = generateExtraRoutesFromNuxtConfig(); const prerenderUrlsFinal = [ ...prerenderUrls, ...((await nitroPromise)._prerenderedRoutes || []).filter((r) => (!r.fileName || r.fileName.endsWith(".html")) && !r.route.endsWith(".html") && !r.route.startsWith("/api/")).map((r) => r._sitemap) ]; const pageSource = convertNuxtPagesToSitemapEntries(await pagesPromise, { isI18nMapped, autoLastmod: config.autoLastmod, defaultLocale: nuxtI18nConfig.defaultLocale || "en", strategy: nuxtI18nConfig.strategy || "no_prefix", routesNameSeparator: nuxtI18nConfig.routesNameSeparator, normalisedLocales }); if (!resolvedConfigUrls) { config.urls && userGlobalSources.push({ context: { name: "sitemap:urls", description: "Set with the `sitemap.urls` config." }, urls: await resolveUrls(config.urls) }); resolvedConfigUrls = true; } const globalSources = [ ...userGlobalSources.map((s) => { if (typeof s === "string") { return { sourceType: "user", fetch: s }; } s.sourceType = "user"; return s; }), ...(config.excludeAppSources === true ? [] : [ ...appGlobalSources, { context: { name: "nuxt:pages", description: "Generated from your static page files.", tips: [ "Can be disabled with `{ excludeAppSources: ['nuxt:pages'] }`." ] }, urls: pageSource }, { context: { name: "nuxt:route-rules", description: "Generated from your route rules config.", tips: [ "Can be disabled with `{ excludeAppSources: ['nuxt:route-rules'] }`." ] }, urls: routeRules2 }, { context: { name: "nuxt:prerender", description: "Generated at build time when prerendering.", tips: [ "Can be disabled with `{ excludeAppSources: ['nuxt:prerender'] }`." ] }, urls: prerenderUrlsFinal } ]).filter((s) => !config.excludeAppSources.includes(s.context.name) && (!!s.urls?.length || !!s.fetch)).map((s) => { s.sourceType = "app"; return s; }) ]; return `export const sources = ${JSON.stringify(globalSources, null, 4)}`; }; const extraSitemapModules = typeof config.sitemaps == "object" ? Object.keys(config.sitemaps).filter((n) => n !== "index") : []; const sitemapSources = {}; nitroConfig.virtual[`#nuxt-simple-sitemap/child-sources.mjs`] = async () => { for (const sitemapName of extraSitemapModules) { sitemapSources[sitemapName] = sitemapSources[sitemapName] || []; const definition = config.sitemaps[sitemapName]; if (!sitemapSources[sitemapName].length) { definition.urls && sitemapSources[sitemapName].push({ context: { name: `sitemaps:${sitemapName}:urls`, description: "Set with the `sitemap.urls` config." }, urls: await resolveUrls(definition.urls) }); definition.dynamicUrlsApiEndpoint && sitemapSources[sitemapName].push({ context: { name: `${sitemapName}:dynamicUrlsApiEndpoint`, description: `Generated from your ${sitemapName}:dynamicUrlsApiEndpoint config.`, tips: [ `You should switch to using the \`sitemaps.${sitemapName}.sources\` config which also supports fetch options.` ] }, fetch: definition.dynamicUrlsApiEndpoint }); sitemapSources[sitemapName].push( ...(definition.sources || []).map((s) => { if (typeof s === "string") { return { sourceType: "user", fetch: s }; } s.sourceType = "user"; return s; }) ); } } return `export const sources = ${JSON.stringify(sitemapSources, null, 4)}`; }; }); if (config.xsl === "/__sitemap__/style.xsl") { addServerHandler({ route: config.xsl, handler: resolve("./runtime/routes/sitemap.xsl") }); config.xsl = withBase(config.xsl, nuxt.options.app.baseURL); if (prerenderSitemap) addPrerenderRoutes(config.xsl); } addServerHandler({ route: `/${config.sitemapName}`, handler: resolve("./runtime/routes/sitemap.xml") }); setupPrerenderHandler(runtimeConfig); } }); export { module as default };