Skip to content

Commit e4a987c

Browse files
authoredJan 15, 2025··
feat: sitemap:input Nitro hook (#397)
1 parent 5e934f0 commit e4a987c

File tree

15 files changed

+161
-71
lines changed

15 files changed

+161
-71
lines changed
 

‎docs/content/5.nitro-api/nitro-hooks.md

+28-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,39 @@ description: Learn how to use Nitro Hooks to customize your sitemap entries.
55

66
Nitro hooks can be added to modify the output of your sitemaps at runtime.
77

8+
## `'sitemap:input'`{lang="ts"}
9+
10+
**Type:** `async (ctx: { urls: SitemapUrlInput[]; sitemapName: string }) => void | Promise<void>`{lang="ts"}
11+
12+
Triggers once the raw list of URLs is collected from sources.
13+
14+
This hook is best used for inserting new URLs into the sitemap.
15+
16+
```ts [server/plugins/sitemap.ts]
17+
import { defineNitroPlugin } from 'nitropack/runtime'
18+
19+
export default defineNitroPlugin((nitroApp) => {
20+
nitroApp.hooks.hook('sitemap:resolved', async (ctx) => {
21+
// SitemapUrlInput is either a string
22+
ctx.urls.push('/foo')
23+
// or an object with loc, changefreq, and priority
24+
ctx.urls.push({
25+
loc: '/bar',
26+
changefreq: 'daily',
27+
priority: 0.8,
28+
})
29+
})
30+
})
31+
```
32+
833
## `'sitemap:resolved'`{lang="ts"}
934

10-
**Type:** `async (ctx: { urls: SitemapConfig; sitemapName: string }) => void | Promise<void>`{lang="ts"}
35+
**Type:** `async (ctx: { urls: ResolvedSitemapUrl[]; sitemapName: string }) => void | Promise<void>`{lang="ts"}
1136

1237
Triggered once the final structure of the XML is generated, provides the URLs as objects.
1338

39+
For new URLs it's recommended to use `sitemap:input` instead. Use this hook for modifying entries or removing them.
40+
1441
```ts [server/plugins/sitemap.ts]
1542
import { defineNitroPlugin } from 'nitropack/runtime'
1643

‎src/module.ts

+1
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ declare module 'nitropack' {
285285
}
286286
interface NitroRuntimeHooks {
287287
'sitemap:index-resolved': (ctx: import('${typesPath}').SitemapIndexRenderCtx) => void | Promise<void>
288+
'sitemap:input': (ctx: import('${typesPath}').SitemapInputCtx) => void | Promise<void>
288289
'sitemap:resolved': (ctx: import('${typesPath}').SitemapRenderCtx) => void | Promise<void>
289290
'sitemap:output': (ctx: import('${typesPath}').SitemapOutputHookCtx) => void | Promise<void>
290291
}

‎src/runtime/server/routes/sitemap_index.xml.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default defineEventHandler(async (e) => {
1010
const runtimeConfig = useSimpleSitemapRuntimeConfig()
1111
const nitro = useNitroApp()
1212
const resolvers = useNitroUrlResolvers(e)
13-
const sitemaps = (await buildSitemapIndex(resolvers, runtimeConfig))
13+
const sitemaps = await buildSitemapIndex(resolvers, runtimeConfig, nitro)
1414

1515
// tell the prerender to render the other sitemaps (if we prerender this one)
1616
// this solves the dynamic chunking sitemap issue

‎src/runtime/server/sitemap/builder/sitemap-index.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { defu } from 'defu'
22
import { joinURL } from 'ufo'
3+
import type { NitroApp } from 'nitropack/types'
34
import type {
45
ModuleRuntimeConfig,
56
NitroUrlResolvers,
67
ResolvedSitemapUrl,
7-
SitemapIndexEntry,
8+
SitemapIndexEntry, SitemapInputCtx,
89
SitemapUrl,
910
} from '../../../types'
1011
import { normaliseDate } from '../urlset/normalise'
@@ -13,7 +14,7 @@ import { sortSitemapUrls } from '../urlset/sort'
1314
import { escapeValueForXml, wrapSitemapXml } from './xml'
1415
import { resolveSitemapEntries } from './sitemap'
1516

16-
export async function buildSitemapIndex(resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig) {
17+
export async function buildSitemapIndex(resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp) {
1718
const {
1819
sitemaps,
1920
// enhancing
@@ -39,7 +40,12 @@ export async function buildSitemapIndex(resolvers: NitroUrlResolvers, runtimeCon
3940
const sitemap = sitemaps.chunks
4041
// we need to figure out how many entries we're dealing with
4142
const sources = await resolveSitemapSources(await globalSitemapSources())
42-
const normalisedUrls = resolveSitemapEntries(sitemap, sources, { autoI18n, isI18nMapped }, resolvers)
43+
const resolvedCtx: SitemapInputCtx = {
44+
urls: sources.flatMap(s => s.urls),
45+
sitemapName: sitemap.sitemapName,
46+
}
47+
await nitro?.hooks.callHook('sitemap:input', resolvedCtx)
48+
const normalisedUrls = resolveSitemapEntries(sitemap, resolvedCtx.urls, { autoI18n, isI18nMapped }, resolvers)
4349
// 2. enhance
4450
const enhancedUrls: ResolvedSitemapUrl[] = normalisedUrls
4551
.map(e => defu(e, sitemap.defaults) as ResolvedSitemapUrl)

‎src/runtime/server/sitemap/builder/sitemap.ts

+14-10
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { resolveSitePath } from 'nuxt-site-config/urls'
22
import { joinURL, withHttps } from 'ufo'
3+
import type { NitroApp } from 'nitropack/types'
34
import type {
45
AlternativeEntry, AutoI18nConfig,
56
ModuleRuntimeConfig,
67
NitroUrlResolvers,
78
ResolvedSitemapUrl,
8-
SitemapDefinition,
9-
SitemapSourceResolved,
9+
SitemapDefinition, SitemapInputCtx,
1010
SitemapUrlInput,
1111
} from '../../../types'
1212
import { preNormalizeEntry } from '../urlset/normalise'
@@ -21,7 +21,7 @@ export interface NormalizedI18n extends ResolvedSitemapUrl {
2121
_index?: number
2222
}
2323

24-
export function resolveSitemapEntries(sitemap: SitemapDefinition, sources: SitemapSourceResolved[], runtimeConfig: Pick<ModuleRuntimeConfig, 'autoI18n' | 'isI18nMapped'>, resolvers?: NitroUrlResolvers): ResolvedSitemapUrl[] {
24+
export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapUrlInput[], runtimeConfig: Pick<ModuleRuntimeConfig, 'autoI18n' | 'isI18nMapped'>, resolvers?: NitroUrlResolvers): ResolvedSitemapUrl[] {
2525
const {
2626
autoI18n,
2727
isI18nMapped,
@@ -31,7 +31,7 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, sources: Sitem
3131
exclude: sitemap.exclude,
3232
})
3333
// 1. normalise
34-
const _urls = sources.flatMap(e => e.urls).map((_e) => {
34+
const _urls = urls.map((_e) => {
3535
const e = preNormalizeEntry(_e, resolvers)
3636
if (!e.loc || !filterPath(e.loc))
3737
return false
@@ -176,7 +176,7 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, sources: Sitem
176176
return _urls
177177
}
178178

179-
export async function buildSitemapUrls(sitemap: SitemapDefinition, resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig) {
179+
export async function buildSitemapUrls(sitemap: SitemapDefinition, resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp) {
180180
// 0. resolve sources
181181
// 1. normalise
182182
// 2. filter
@@ -222,11 +222,15 @@ export async function buildSitemapUrls(sitemap: SitemapDefinition, resolvers: Ni
222222
}
223223
// 0. resolve sources
224224
// always fetch all sitemap data for the primary sitemap
225-
const sources = sitemap.includeAppSources ? await globalSitemapSources() : []
226-
sources.push(...await childSitemapSources(sitemap))
227-
const resolvedSources = await resolveSitemapSources(sources, resolvers.event)
228-
229-
const enhancedUrls = resolveSitemapEntries(sitemap, resolvedSources, { autoI18n, isI18nMapped }, resolvers)
225+
const sourcesInput = sitemap.includeAppSources ? await globalSitemapSources() : []
226+
sourcesInput.push(...await childSitemapSources(sitemap))
227+
const sources = await resolveSitemapSources(sourcesInput, resolvers.event)
228+
const resolvedCtx: SitemapInputCtx = {
229+
urls: sources.flatMap(s => s.urls),
230+
sitemapName: sitemap.sitemapName,
231+
}
232+
await nitro?.hooks.callHook('sitemap:input', resolvedCtx)
233+
const enhancedUrls = resolveSitemapEntries(sitemap, resolvedCtx.urls, { autoI18n, isI18nMapped }, resolvers)
230234
// 3. filtered urls
231235
// TODO make sure include and exclude start with baseURL?
232236
const filteredUrls = enhancedUrls.filter((e) => {

‎src/runtime/server/sitemap/nitro.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
import { logger, mergeOnKey, splitForLocales } from '../../utils-pure'
1313
import { createNitroRouteRuleMatcher } from '../kit'
1414
import { buildSitemapUrls, urlsToXml } from './builder/sitemap'
15-
import { normaliseEntry } from './urlset/normalise'
15+
import { normaliseEntry, preNormalizeEntry } from './urlset/normalise'
1616
import { sortSitemapUrls } from './urlset/sort'
1717
import { useNitroApp, createSitePathResolver, getPathRobotConfig, useSiteConfig } from '#imports'
1818

@@ -49,7 +49,7 @@ export async function createSitemap(event: H3Event, definition: SitemapDefinitio
4949
}
5050
}
5151
const resolvers = useNitroUrlResolvers(event)
52-
let sitemapUrls = await buildSitemapUrls(definition, resolvers, runtimeConfig)
52+
let sitemapUrls = await buildSitemapUrls(definition, resolvers, runtimeConfig, nitro)
5353

5454
const routeRuleMatcher = createNitroRouteRuleMatcher()
5555
const { autoI18n } = runtimeConfig
@@ -84,11 +84,17 @@ export async function createSitemap(event: H3Event, definition: SitemapDefinitio
8484
}).filter(Boolean)
8585

8686
// 6. nitro hooks
87+
const locSize = sitemapUrls.length
8788
const resolvedCtx: SitemapRenderCtx = {
8889
urls: sitemapUrls,
8990
sitemapName: sitemapName,
9091
}
9192
await nitro.hooks.callHook('sitemap:resolved', resolvedCtx)
93+
// we need to normalize any new urls otherwise they won't appear in the final sitemap
94+
// Note this is risky and users should be using the sitemap:input hook for additions
95+
if (resolvedCtx.urls.length !== locSize) {
96+
resolvedCtx.urls = resolvedCtx.urls.map(e => preNormalizeEntry(e, resolvers))
97+
}
9298

9399
const maybeSort = (urls: ResolvedSitemapUrl[]) => runtimeConfig.sortEntries ? sortSitemapUrls(urls) : urls
94100
// final urls

‎src/runtime/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ export interface SitemapRenderCtx {
312312
urls: ResolvedSitemapUrl[]
313313
}
314314

315+
export interface SitemapInputCtx {
316+
sitemapName: string
317+
urls: SitemapUrlInput[]
318+
}
319+
315320
export interface SitemapOutputHookCtx {
316321
sitemapName: string
317322
sitemap: string

‎test/bench/i18n.bench.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe('i18n', () => {
1818
bench('normaliseI18nSources', () => {
1919
resolveSitemapEntries({
2020
sitemapName: 'sitemap.xml',
21-
}, sources, {
21+
}, sources.flatMap(s => s.urls), {
2222
autoI18n: {
2323
locales: [
2424
{ code: 'en', iso: 'en' },

‎test/bench/normalize.bench.ts

-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { bench, describe } from 'vitest'
22
import { preNormalizeEntry } from '../../src/runtime/server/sitemap/urlset/normalise'
33
import type { SitemapSourceResolved } from '#sitemap'
4-
import { resolveSitemapEntries } from '~/src/runtime/server/sitemap/builder/sitemap'
54

65
const sources: SitemapSourceResolved[] = [
76
{
@@ -17,7 +16,6 @@ const sources: SitemapSourceResolved[] = [
1716

1817
describe('normalize', () => {
1918
bench('preNormalizeEntry', () => {
20-
resolveSitemapEntries(sources)
2119
const urls = sources.flatMap(s => s.urls)
2220
urls.map(u => preNormalizeEntry(u))
2321
}, {

‎test/fixtures/basic/nuxt.config.ts

+6
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@ export default defineNuxtConfig({
55
modules: [
66
NuxtSitemap,
77
],
8+
89
site: {
910
url: 'https://nuxtseo.com',
1011
},
12+
1113
routeRules: {
1214
'/foo-redirect': {
1315
redirect: '/foo',
1416
},
1517
},
18+
19+
compatibilityDate: '2025-01-15',
20+
1621
debug: process.env.NODE_ENV === 'test',
22+
1723
sitemap: {
1824
autoLastmod: false,
1925
credits: false,

‎test/fixtures/hooks/nuxt.config.ts

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import NuxtSitemap from '../../../src/module'
2+
3+
// https://v3.nuxtjs.org/api/configuration/nuxt.config
4+
export default defineNuxtConfig({
5+
modules: [
6+
NuxtSitemap,
7+
],
8+
9+
site: {
10+
url: 'https://nuxtseo.com',
11+
},
12+
13+
routeRules: {
14+
'/foo-redirect': {
15+
redirect: '/foo',
16+
},
17+
},
18+
19+
compatibilityDate: '2025-01-15',
20+
21+
debug: process.env.NODE_ENV === 'test',
22+
23+
sitemap: {
24+
autoLastmod: false,
25+
credits: false,
26+
debug: true,
27+
},
28+
})

‎test/fixtures/hooks/pages/index.vue

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<template>
2+
<div>
3+
<a href="/sitemap.xml">
4+
sitemap.xml
5+
</a>
6+
</div>
7+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineNitroPlugin } from 'nitropack/runtime'
2+
3+
export default defineNitroPlugin((nitroApp) => {
4+
nitroApp.hooks.hook('sitemap:input', async (ctx) => {
5+
ctx.urls.push({
6+
loc: '/test-1',
7+
})
8+
9+
ctx.urls.push({
10+
loc: '/test-2',
11+
})
12+
})
13+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineEventHandler } from 'h3'
2+
3+
export default defineEventHandler(() => {
4+
return [
5+
'/__sitemap/url',
6+
{
7+
loc: '/__sitemap/loc',
8+
},
9+
{
10+
loc: 'https://nuxtseo.com/__sitemap/abs',
11+
},
12+
]
13+
})

‎test/unit/i18n.test.ts

+27-51
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,13 @@ describe('i18n', () => {
5656
it('_i18nTransform without prefix', () => {
5757
const urls = resolveSitemapEntries({
5858
sitemapName: 'sitemap.xml',
59-
}, [{
60-
urls: [
61-
{
62-
loc: '/__sitemap/url',
63-
changefreq: 'weekly',
64-
_i18nTransform: true,
65-
},
66-
],
67-
context: {
68-
name: 'foo',
59+
}, [
60+
{
61+
loc: '/__sitemap/url',
62+
changefreq: 'weekly',
63+
_i18nTransform: true,
6964
},
70-
sourceType: 'user',
71-
}], {
65+
], {
7266
locales: EnFrAutoI18n.locales,
7367
defaultLocale: 'en',
7468
strategy: 'no_prefix',
@@ -95,19 +89,13 @@ describe('i18n', () => {
9589
it('_i18nTransform prefix_except_default', () => {
9690
const urls = resolveSitemapEntries({
9791
sitemapName: 'sitemap.xml',
98-
}, [{
99-
urls: [
100-
{
101-
loc: '/__sitemap/url',
102-
changefreq: 'weekly',
103-
_i18nTransform: true,
104-
},
105-
],
106-
context: {
107-
name: 'foo',
92+
}, [
93+
{
94+
loc: '/__sitemap/url',
95+
changefreq: 'weekly',
96+
_i18nTransform: true,
10897
},
109-
sourceType: 'user',
110-
}], {
98+
], {
11199
autoI18n: {
112100
locales: EnFrAutoI18n.locales,
113101
defaultLocale: 'en',
@@ -195,35 +183,23 @@ describe('i18n', () => {
195183
it('applies alternative links', () => {
196184
const urls = resolveSitemapEntries({
197185
sitemapName: 'sitemap.xml',
198-
}, [{
199-
urls: [],
200-
context: {
201-
name: 'foo',
186+
}, [
187+
{
188+
loc: '/en/dynamic/foo',
202189
},
203-
sourceType: 'user',
204-
}, {
205-
urls: [
206-
{
207-
loc: '/en/dynamic/foo',
208-
},
209-
{
210-
loc: '/fr/dynamic/foo',
211-
},
212-
{
213-
loc: 'endless-dungeon', // issue with en being picked up as the locale
214-
_i18nTransform: true,
215-
},
216-
{
217-
loc: 'english-url', // issue with en being picked up as the locale
218-
},
219-
// absolute URL issue
220-
{ loc: 'https://www.somedomain.com/abc/def' },
221-
],
222-
context: {
223-
name: 'foo',
190+
{
191+
loc: '/fr/dynamic/foo',
192+
},
193+
{
194+
loc: 'endless-dungeon', // issue with en being picked up as the locale
195+
_i18nTransform: true,
196+
},
197+
{
198+
loc: 'english-url', // issue with en being picked up as the locale
224199
},
225-
sourceType: 'user',
226-
}], {
200+
// absolute URL issue
201+
{ loc: 'https://www.somedomain.com/abc/def' },
202+
], {
227203
autoI18n: EnFrAutoI18n,
228204
isI18nMapped: true,
229205
})

0 commit comments

Comments
 (0)
Please sign in to comment.