diff --git a/docs/content/5.nitro-api/nitro-hooks.md b/docs/content/5.nitro-api/nitro-hooks.md index b43722bb..ffd55ba3 100644 --- a/docs/content/5.nitro-api/nitro-hooks.md +++ b/docs/content/5.nitro-api/nitro-hooks.md @@ -5,12 +5,39 @@ description: Learn how to use Nitro Hooks to customize your sitemap entries. Nitro hooks can be added to modify the output of your sitemaps at runtime. +## `'sitemap:input'`{lang="ts"} + +**Type:** `async (ctx: { urls: SitemapUrlInput[]; sitemapName: string }) => void | Promise`{lang="ts"} + +Triggers once the raw list of URLs is collected from sources. + +This hook is best used for inserting new URLs into the sitemap. + +```ts [server/plugins/sitemap.ts] +import { defineNitroPlugin } from 'nitropack/runtime' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('sitemap:resolved', async (ctx) => { + // SitemapUrlInput is either a string + ctx.urls.push('/foo') + // or an object with loc, changefreq, and priority + ctx.urls.push({ + loc: '/bar', + changefreq: 'daily', + priority: 0.8, + }) + }) +}) +``` + ## `'sitemap:resolved'`{lang="ts"} -**Type:** `async (ctx: { urls: SitemapConfig; sitemapName: string }) => void | Promise`{lang="ts"} +**Type:** `async (ctx: { urls: ResolvedSitemapUrl[]; sitemapName: string }) => void | Promise`{lang="ts"} Triggered once the final structure of the XML is generated, provides the URLs as objects. +For new URLs it's recommended to use `sitemap:input` instead. Use this hook for modifying entries or removing them. + ```ts [server/plugins/sitemap.ts] import { defineNitroPlugin } from 'nitropack/runtime' diff --git a/src/module.ts b/src/module.ts index 3c453e2e..d556134c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -285,6 +285,7 @@ declare module 'nitropack' { } interface NitroRuntimeHooks { 'sitemap:index-resolved': (ctx: import('${typesPath}').SitemapIndexRenderCtx) => void | Promise + 'sitemap:input': (ctx: import('${typesPath}').SitemapInputCtx) => void | Promise 'sitemap:resolved': (ctx: import('${typesPath}').SitemapRenderCtx) => void | Promise 'sitemap:output': (ctx: import('${typesPath}').SitemapOutputHookCtx) => void | Promise } diff --git a/src/runtime/server/routes/sitemap_index.xml.ts b/src/runtime/server/routes/sitemap_index.xml.ts index a108d0e4..1027f1f5 100644 --- a/src/runtime/server/routes/sitemap_index.xml.ts +++ b/src/runtime/server/routes/sitemap_index.xml.ts @@ -10,7 +10,7 @@ export default defineEventHandler(async (e) => { const runtimeConfig = useSimpleSitemapRuntimeConfig() const nitro = useNitroApp() const resolvers = useNitroUrlResolvers(e) - const sitemaps = (await buildSitemapIndex(resolvers, runtimeConfig)) + const sitemaps = await buildSitemapIndex(resolvers, runtimeConfig, nitro) // tell the prerender to render the other sitemaps (if we prerender this one) // this solves the dynamic chunking sitemap issue diff --git a/src/runtime/server/sitemap/builder/sitemap-index.ts b/src/runtime/server/sitemap/builder/sitemap-index.ts index 041663c1..8c2fdeee 100644 --- a/src/runtime/server/sitemap/builder/sitemap-index.ts +++ b/src/runtime/server/sitemap/builder/sitemap-index.ts @@ -1,10 +1,11 @@ import { defu } from 'defu' import { joinURL } from 'ufo' +import type { NitroApp } from 'nitropack/types' import type { ModuleRuntimeConfig, NitroUrlResolvers, ResolvedSitemapUrl, - SitemapIndexEntry, + SitemapIndexEntry, SitemapInputCtx, SitemapUrl, } from '../../../types' import { normaliseDate } from '../urlset/normalise' @@ -13,7 +14,7 @@ import { sortSitemapUrls } from '../urlset/sort' import { escapeValueForXml, wrapSitemapXml } from './xml' import { resolveSitemapEntries } from './sitemap' -export async function buildSitemapIndex(resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig) { +export async function buildSitemapIndex(resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp) { const { sitemaps, // enhancing @@ -39,7 +40,12 @@ export async function buildSitemapIndex(resolvers: NitroUrlResolvers, runtimeCon const sitemap = sitemaps.chunks // we need to figure out how many entries we're dealing with const sources = await resolveSitemapSources(await globalSitemapSources()) - const normalisedUrls = resolveSitemapEntries(sitemap, sources, { autoI18n, isI18nMapped }, resolvers) + const resolvedCtx: SitemapInputCtx = { + urls: sources.flatMap(s => s.urls), + sitemapName: sitemap.sitemapName, + } + await nitro?.hooks.callHook('sitemap:input', resolvedCtx) + const normalisedUrls = resolveSitemapEntries(sitemap, resolvedCtx.urls, { autoI18n, isI18nMapped }, resolvers) // 2. enhance const enhancedUrls: ResolvedSitemapUrl[] = normalisedUrls .map(e => defu(e, sitemap.defaults) as ResolvedSitemapUrl) diff --git a/src/runtime/server/sitemap/builder/sitemap.ts b/src/runtime/server/sitemap/builder/sitemap.ts index 655c7c66..009ccbae 100644 --- a/src/runtime/server/sitemap/builder/sitemap.ts +++ b/src/runtime/server/sitemap/builder/sitemap.ts @@ -1,12 +1,12 @@ import { resolveSitePath } from 'nuxt-site-config/urls' import { joinURL, withHttps } from 'ufo' +import type { NitroApp } from 'nitropack/types' import type { AlternativeEntry, AutoI18nConfig, ModuleRuntimeConfig, NitroUrlResolvers, ResolvedSitemapUrl, - SitemapDefinition, - SitemapSourceResolved, + SitemapDefinition, SitemapInputCtx, SitemapUrlInput, } from '../../../types' import { preNormalizeEntry } from '../urlset/normalise' @@ -21,7 +21,7 @@ export interface NormalizedI18n extends ResolvedSitemapUrl { _index?: number } -export function resolveSitemapEntries(sitemap: SitemapDefinition, sources: SitemapSourceResolved[], runtimeConfig: Pick, resolvers?: NitroUrlResolvers): ResolvedSitemapUrl[] { +export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapUrlInput[], runtimeConfig: Pick, resolvers?: NitroUrlResolvers): ResolvedSitemapUrl[] { const { autoI18n, isI18nMapped, @@ -31,7 +31,7 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, sources: Sitem exclude: sitemap.exclude, }) // 1. normalise - const _urls = sources.flatMap(e => e.urls).map((_e) => { + const _urls = urls.map((_e) => { const e = preNormalizeEntry(_e, resolvers) if (!e.loc || !filterPath(e.loc)) return false @@ -176,7 +176,7 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, sources: Sitem return _urls } -export async function buildSitemapUrls(sitemap: SitemapDefinition, resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig) { +export async function buildSitemapUrls(sitemap: SitemapDefinition, resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp) { // 0. resolve sources // 1. normalise // 2. filter @@ -222,11 +222,15 @@ export async function buildSitemapUrls(sitemap: SitemapDefinition, resolvers: Ni } // 0. resolve sources // always fetch all sitemap data for the primary sitemap - const sources = sitemap.includeAppSources ? await globalSitemapSources() : [] - sources.push(...await childSitemapSources(sitemap)) - const resolvedSources = await resolveSitemapSources(sources, resolvers.event) - - const enhancedUrls = resolveSitemapEntries(sitemap, resolvedSources, { autoI18n, isI18nMapped }, resolvers) + const sourcesInput = sitemap.includeAppSources ? await globalSitemapSources() : [] + sourcesInput.push(...await childSitemapSources(sitemap)) + const sources = await resolveSitemapSources(sourcesInput, resolvers.event) + const resolvedCtx: SitemapInputCtx = { + urls: sources.flatMap(s => s.urls), + sitemapName: sitemap.sitemapName, + } + await nitro?.hooks.callHook('sitemap:input', resolvedCtx) + const enhancedUrls = resolveSitemapEntries(sitemap, resolvedCtx.urls, { autoI18n, isI18nMapped }, resolvers) // 3. filtered urls // TODO make sure include and exclude start with baseURL? const filteredUrls = enhancedUrls.filter((e) => { diff --git a/src/runtime/server/sitemap/nitro.ts b/src/runtime/server/sitemap/nitro.ts index cb96434b..1e03de54 100644 --- a/src/runtime/server/sitemap/nitro.ts +++ b/src/runtime/server/sitemap/nitro.ts @@ -12,7 +12,7 @@ import type { import { logger, mergeOnKey, splitForLocales } from '../../utils-pure' import { createNitroRouteRuleMatcher } from '../kit' import { buildSitemapUrls, urlsToXml } from './builder/sitemap' -import { normaliseEntry } from './urlset/normalise' +import { normaliseEntry, preNormalizeEntry } from './urlset/normalise' import { sortSitemapUrls } from './urlset/sort' import { useNitroApp, createSitePathResolver, getPathRobotConfig, useSiteConfig } from '#imports' @@ -49,7 +49,7 @@ export async function createSitemap(event: H3Event, definition: SitemapDefinitio } } const resolvers = useNitroUrlResolvers(event) - let sitemapUrls = await buildSitemapUrls(definition, resolvers, runtimeConfig) + let sitemapUrls = await buildSitemapUrls(definition, resolvers, runtimeConfig, nitro) const routeRuleMatcher = createNitroRouteRuleMatcher() const { autoI18n } = runtimeConfig @@ -84,11 +84,17 @@ export async function createSitemap(event: H3Event, definition: SitemapDefinitio }).filter(Boolean) // 6. nitro hooks + const locSize = sitemapUrls.length const resolvedCtx: SitemapRenderCtx = { urls: sitemapUrls, sitemapName: sitemapName, } await nitro.hooks.callHook('sitemap:resolved', resolvedCtx) + // we need to normalize any new urls otherwise they won't appear in the final sitemap + // Note this is risky and users should be using the sitemap:input hook for additions + if (resolvedCtx.urls.length !== locSize) { + resolvedCtx.urls = resolvedCtx.urls.map(e => preNormalizeEntry(e, resolvers)) + } const maybeSort = (urls: ResolvedSitemapUrl[]) => runtimeConfig.sortEntries ? sortSitemapUrls(urls) : urls // final urls diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 20c2bafe..110fc4b4 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -312,6 +312,11 @@ export interface SitemapRenderCtx { urls: ResolvedSitemapUrl[] } +export interface SitemapInputCtx { + sitemapName: string + urls: SitemapUrlInput[] +} + export interface SitemapOutputHookCtx { sitemapName: string sitemap: string diff --git a/test/bench/i18n.bench.ts b/test/bench/i18n.bench.ts index 9210aab0..cdcaa730 100644 --- a/test/bench/i18n.bench.ts +++ b/test/bench/i18n.bench.ts @@ -18,7 +18,7 @@ describe('i18n', () => { bench('normaliseI18nSources', () => { resolveSitemapEntries({ sitemapName: 'sitemap.xml', - }, sources, { + }, sources.flatMap(s => s.urls), { autoI18n: { locales: [ { code: 'en', iso: 'en' }, diff --git a/test/bench/normalize.bench.ts b/test/bench/normalize.bench.ts index e68dfcb1..29d49782 100644 --- a/test/bench/normalize.bench.ts +++ b/test/bench/normalize.bench.ts @@ -1,7 +1,6 @@ import { bench, describe } from 'vitest' import { preNormalizeEntry } from '../../src/runtime/server/sitemap/urlset/normalise' import type { SitemapSourceResolved } from '#sitemap' -import { resolveSitemapEntries } from '~/src/runtime/server/sitemap/builder/sitemap' const sources: SitemapSourceResolved[] = [ { @@ -17,7 +16,6 @@ const sources: SitemapSourceResolved[] = [ describe('normalize', () => { bench('preNormalizeEntry', () => { - resolveSitemapEntries(sources) const urls = sources.flatMap(s => s.urls) urls.map(u => preNormalizeEntry(u)) }, { diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 8b608838..ea2353c1 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -5,15 +5,21 @@ export default defineNuxtConfig({ modules: [ NuxtSitemap, ], + site: { url: 'https://nuxtseo.com', }, + routeRules: { '/foo-redirect': { redirect: '/foo', }, }, + + compatibilityDate: '2025-01-15', + debug: process.env.NODE_ENV === 'test', + sitemap: { autoLastmod: false, credits: false, diff --git a/test/fixtures/hooks/nuxt.config.ts b/test/fixtures/hooks/nuxt.config.ts new file mode 100644 index 00000000..ea2353c1 --- /dev/null +++ b/test/fixtures/hooks/nuxt.config.ts @@ -0,0 +1,28 @@ +import NuxtSitemap from '../../../src/module' + +// https://v3.nuxtjs.org/api/configuration/nuxt.config +export default defineNuxtConfig({ + modules: [ + NuxtSitemap, + ], + + site: { + url: 'https://nuxtseo.com', + }, + + routeRules: { + '/foo-redirect': { + redirect: '/foo', + }, + }, + + compatibilityDate: '2025-01-15', + + debug: process.env.NODE_ENV === 'test', + + sitemap: { + autoLastmod: false, + credits: false, + debug: true, + }, +}) diff --git a/test/fixtures/hooks/pages/index.vue b/test/fixtures/hooks/pages/index.vue new file mode 100644 index 00000000..15efb978 --- /dev/null +++ b/test/fixtures/hooks/pages/index.vue @@ -0,0 +1,7 @@ + diff --git a/test/fixtures/hooks/server/plugins/sitemap.ts b/test/fixtures/hooks/server/plugins/sitemap.ts new file mode 100644 index 00000000..c31080a1 --- /dev/null +++ b/test/fixtures/hooks/server/plugins/sitemap.ts @@ -0,0 +1,13 @@ +import { defineNitroPlugin } from 'nitropack/runtime' + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('sitemap:input', async (ctx) => { + ctx.urls.push({ + loc: '/test-1', + }) + + ctx.urls.push({ + loc: '/test-2', + }) + }) +}) diff --git a/test/fixtures/hooks/server/routes/__sitemap.ts b/test/fixtures/hooks/server/routes/__sitemap.ts new file mode 100644 index 00000000..8c48bf2a --- /dev/null +++ b/test/fixtures/hooks/server/routes/__sitemap.ts @@ -0,0 +1,13 @@ +import { defineEventHandler } from 'h3' + +export default defineEventHandler(() => { + return [ + '/__sitemap/url', + { + loc: '/__sitemap/loc', + }, + { + loc: 'https://nuxtseo.com/__sitemap/abs', + }, + ] +}) diff --git a/test/unit/i18n.test.ts b/test/unit/i18n.test.ts index f8f84da0..16a55fdd 100644 --- a/test/unit/i18n.test.ts +++ b/test/unit/i18n.test.ts @@ -56,19 +56,13 @@ describe('i18n', () => { it('_i18nTransform without prefix', () => { const urls = resolveSitemapEntries({ sitemapName: 'sitemap.xml', - }, [{ - urls: [ - { - loc: '/__sitemap/url', - changefreq: 'weekly', - _i18nTransform: true, - }, - ], - context: { - name: 'foo', + }, [ + { + loc: '/__sitemap/url', + changefreq: 'weekly', + _i18nTransform: true, }, - sourceType: 'user', - }], { + ], { locales: EnFrAutoI18n.locales, defaultLocale: 'en', strategy: 'no_prefix', @@ -95,19 +89,13 @@ describe('i18n', () => { it('_i18nTransform prefix_except_default', () => { const urls = resolveSitemapEntries({ sitemapName: 'sitemap.xml', - }, [{ - urls: [ - { - loc: '/__sitemap/url', - changefreq: 'weekly', - _i18nTransform: true, - }, - ], - context: { - name: 'foo', + }, [ + { + loc: '/__sitemap/url', + changefreq: 'weekly', + _i18nTransform: true, }, - sourceType: 'user', - }], { + ], { autoI18n: { locales: EnFrAutoI18n.locales, defaultLocale: 'en', @@ -195,35 +183,23 @@ describe('i18n', () => { it('applies alternative links', () => { const urls = resolveSitemapEntries({ sitemapName: 'sitemap.xml', - }, [{ - urls: [], - context: { - name: 'foo', + }, [ + { + loc: '/en/dynamic/foo', }, - sourceType: 'user', - }, { - urls: [ - { - loc: '/en/dynamic/foo', - }, - { - loc: '/fr/dynamic/foo', - }, - { - loc: 'endless-dungeon', // issue with en being picked up as the locale - _i18nTransform: true, - }, - { - loc: 'english-url', // issue with en being picked up as the locale - }, - // absolute URL issue - { loc: 'https://www.somedomain.com/abc/def' }, - ], - context: { - name: 'foo', + { + loc: '/fr/dynamic/foo', + }, + { + loc: 'endless-dungeon', // issue with en being picked up as the locale + _i18nTransform: true, + }, + { + loc: 'english-url', // issue with en being picked up as the locale }, - sourceType: 'user', - }], { + // absolute URL issue + { loc: 'https://www.somedomain.com/abc/def' }, + ], { autoI18n: EnFrAutoI18n, isI18nMapped: true, })