Skip to content

Commit 45d522f

Browse files
authored
perf: reduce sitemap generation overhead (#633)
1 parent 7bdc598 commit 45d522f

21 files changed

Lines changed: 465 additions & 119 deletions

‎src/prerender.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export function setupPrerenderHandler(_options: { runtimeConfig: ModuleRuntimeCo
4949
nuxt.options.nitro.prerender.routes = nuxt.options.nitro.prerender.routes.filter(r => r && !includesSitemapRoot(options.sitemapName, [r]))
5050

5151
const runtimeAssetsPath = join(nuxt.options.rootDir, 'node_modules/.cache/nuxt/sitemap')
52+
const localeCodes = options.autoI18n ? new Set(options.autoI18n.locales.map(l => l.code)) : undefined
5253

5354
// Setup virtual module for reading sources - must be in nitro:config to be bundled
5455
nuxt.hooks.hook('nitro:config', (nitroConfig) => {
@@ -112,7 +113,7 @@ export async function readSourcesFromFilesystem(filename) {
112113
// we need to figure out which sitemap this belongs to
113114
if (options.autoI18n && Object.keys(options.sitemaps).length > 1) {
114115
const path = route.route
115-
const match = splitForLocales(path, options.autoI18n.locales.map(l => l.code))
116+
const match = splitForLocales(path, localeCodes!)
116117
// if it's missing a locale then we put it in the default locale sitemap
117118
const locale = match[0] || options.autoI18n.defaultLocale
118119
if (options.isI18nMapped) {

‎src/runtime/server/kit.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ import { createRouter as createRadixRouter, toRouteMatcher } from 'radix3'
55
import { parseURL, withoutBase, withoutTrailingSlash } from 'ufo'
66

77
function withoutQuery(path: string): string {
8-
return path.split('?')[0]!
8+
const queryIndex = path.indexOf('?')
9+
return queryIndex === -1 ? path : path.slice(0, queryIndex)
910
}
1011

12+
let cachedRouteRuleMatcher: ((pathOrUrl: string) => NitroRouteRules) | undefined
13+
1114
export function createNitroRouteRuleMatcher(): (pathOrUrl: string) => NitroRouteRules {
15+
if (!import.meta.dev && cachedRouteRuleMatcher)
16+
return cachedRouteRuleMatcher
17+
1218
const { nitro, app } = useRuntimeConfig()
1319
const _routeRulesMatcher = toRouteMatcher(
1420
createRadixRouter({
@@ -18,10 +24,13 @@ export function createNitroRouteRuleMatcher(): (pathOrUrl: string) => NitroRoute
1824
),
1925
}),
2026
)
21-
return (pathOrUrl: string) => {
27+
const matcher = (pathOrUrl: string) => {
2228
const path = pathOrUrl[0] === '/' ? pathOrUrl : parseURL(pathOrUrl, app.baseURL).pathname
2329
return defu({}, ..._routeRulesMatcher.matchAll(
2430
withoutBase(withoutTrailingSlash(withoutQuery(path)), app.baseURL),
2531
).reverse()) as NitroRouteRules
2632
}
33+
if (!import.meta.dev)
34+
cachedRouteRuleMatcher = matcher
35+
return matcher
2736
}

‎src/runtime/server/plugins/compression.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function getPreferredEncoding(event: H3Event): 'gzip' | 'deflate' | null {
1212
}
1313

1414
export default defineNitroPlugin((nitro) => {
15-
nitro.hooks.hook('beforeResponse', async (event, response) => {
15+
nitro.hooks.hook('beforeResponse', (event, response) => {
1616
if (!event.context._isSitemap || !response.body)
1717
return
1818

@@ -21,8 +21,7 @@ export default defineNitroPlugin((nitro) => {
2121
return
2222

2323
const body = typeof response.body === 'string' ? response.body : JSON.stringify(response.body)
24-
const stream = new Blob([body]).stream().pipeThrough(new CompressionStream(encoding))
25-
response.body = Buffer.from(await new Response(stream).arrayBuffer())
24+
response.body = new Blob([body]).stream().pipeThrough(new CompressionStream(encoding))
2625
setResponseHeader(event, 'Content-Encoding', encoding)
2726
})
2827
})

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

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,18 @@ async function buildSitemapIndexInternal(resolvers: NitroUrlResolvers, runtimeCo
146146
}
147147

148148
export function urlsToIndexXml(sitemaps: SitemapIndexEntry[], resolvers: NitroUrlResolvers, { version, xsl, credits, minify }: Pick<ModuleRuntimeConfig, 'version' | 'xsl' | 'credits' | 'minify'>, errorInfo?: { messages: string[], urls: string[] }) {
149-
const sitemapXml = sitemaps.map(e => [
150-
' <sitemap>',
151-
` <loc>${escapeValueForXml(e.sitemap)}</loc>`,
152-
// lastmod is optional
153-
e.lastmod ? ` <lastmod>${escapeValueForXml(e.lastmod)}</lastmod>` : false,
154-
' </sitemap>',
155-
].filter(Boolean).join('\n')).join('\n')
149+
const NL = minify ? '' : '\n'
150+
const I1 = minify ? '' : ' '
151+
const I2 = minify ? '' : ' '
152+
let sitemapXml = ''
153+
for (const entry of sitemaps) {
154+
if (sitemapXml)
155+
sitemapXml += NL
156+
sitemapXml += `${I1}<sitemap>${NL}${I2}<loc>${escapeValueForXml(entry.sitemap)}</loc>${NL}`
157+
if (entry.lastmod)
158+
sitemapXml += `${I2}<lastmod>${escapeValueForXml(entry.lastmod)}</lastmod>${NL}`
159+
sitemapXml += `${I1}</sitemap>`
160+
}
156161

157162
const xmlParts = [
158163
'<?xml version="1.0" encoding="UTF-8"?>',
@@ -186,10 +191,7 @@ export function urlsToIndexXml(sitemaps: SitemapIndexEntry[], resolvers: NitroUr
186191
xmlParts.push(`<!-- XML Sitemap Index generated by @nuxtjs/sitemap v${version} at ${new Date().toISOString()} -->`)
187192
}
188193

189-
// Join with appropriate separator
190-
return minify
191-
? xmlParts.join('').replace(/(?<!<[^>]*)\s(?![^<]*>)/g, '')
192-
: xmlParts.join('\n')
194+
return xmlParts.join(NL)
193195
}
194196

195197
export async function buildSitemapIndex(resolvers: NitroUrlResolvers, runtimeConfig: ModuleRuntimeConfig, nitro?: NitroApp) {

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

Lines changed: 44 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,24 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU
3737
autoI18n,
3838
isI18nMapped,
3939
} = runtimeConfig
40-
const filterPath = createPathFilter({
41-
include: sitemap.include,
42-
exclude: sitemap.exclude,
43-
}, baseURL || '/')
40+
const hasFilters = !!sitemap.include?.length || !!sitemap.exclude?.length
41+
const filterPath = hasFilters
42+
? createPathFilter({
43+
include: sitemap.include,
44+
exclude: sitemap.exclude,
45+
}, baseURL || '/')
46+
: undefined
4447
// 1. normalise
45-
const _urls = urls.map((_e) => {
48+
const _urls: ResolvedSitemapUrl[] = []
49+
for (const _e of urls) {
4650
const e = preNormalizeEntry(_e, resolvers)
47-
if (!e.loc || !filterPath(e.loc))
48-
return false
49-
return e
50-
}).filter(Boolean) as ResolvedSitemapUrl[]
51+
if (e.loc && (!filterPath || filterPath(e.loc, e._path?.pathname)))
52+
_urls.push(e)
53+
}
5154

52-
let validI18nUrlsForTransform: NormalizedI18n[] = []
5355
const withoutPrefixPaths: Record<string, NormalizedI18n[]> = {}
5456
if (autoI18n && autoI18n.strategy !== 'no_prefix') {
55-
const localeCodes = autoI18n.locales.map(l => l.code)
57+
const localeCodes = new Set(autoI18n.locales.map(l => l.code))
5658
// Create locale lookup Map for O(1) access
5759
const localeByCode = new Map(autoI18n.locales.map(l => [l.code, l]))
5860
// Pre-check strategy once
@@ -63,11 +65,14 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU
6365
// Cache frequently accessed values
6466
const defaultLocale = autoI18n.defaultLocale
6567
const hasPages = !!autoI18n.pages
68+
const sortedPageKeys = hasPages ? Object.keys(autoI18n.pages!).sort((a, b) => b.length - a.length) : undefined
6669
const hasDifferentDomains = !!autoI18n.differentDomains
6770

68-
validI18nUrlsForTransform = _urls.map((_e, i) => {
71+
const validI18nUrlsForTransform: NormalizedI18n[] = []
72+
for (let i = 0; i < _urls.length; i++) {
73+
const _e = _urls[i]!
6974
if (_e._abs)
70-
return false
75+
continue
7176
const split = splitForLocales(_e._relativeLoc, localeCodes)
7277
let localeCode = split[0]
7378
const pathWithoutPrefix = split[1]
@@ -78,37 +83,33 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU
7883
// Use Map instead of find for O(1) lookup
7984
const locale = localeByCode.get(localeCode)
8085
if (!locale)
81-
return false
86+
continue
8287
e._locale = locale
8388
e._index = i
8489
e._key = `${e._sitemap || ''}${e._path?.pathname || '/'}${e._path?.search || ''}`
8590
withoutPrefixPaths[pathWithoutPrefix] = withoutPrefixPaths[pathWithoutPrefix] || []
8691
// need to make sure the locale doesn't already exist
8792
if (!withoutPrefixPaths[pathWithoutPrefix].some(e => e._locale.code === locale.code))
8893
withoutPrefixPaths[pathWithoutPrefix].push(e)
89-
return e
90-
}).filter(Boolean) as NormalizedI18n[]
94+
validI18nUrlsForTransform.push(e)
95+
}
9196

9297
for (const e of validI18nUrlsForTransform) {
9398
// let's try and find other urls that we can use for alternatives
9499
if (!e._i18nTransform && !e.alternatives?.length) {
95-
const alternatives = (withoutPrefixPaths[e._pathWithoutPrefix] || [])
96-
.map((u) => {
97-
const entries: AlternativeEntry[] = []
98-
if (u._locale.code === defaultLocale) {
99-
entries.push({
100-
href: u.loc,
101-
hreflang: 'x-default',
102-
})
103-
}
104-
entries.push({
100+
const alternatives: AlternativeEntry[] = []
101+
for (const u of withoutPrefixPaths[e._pathWithoutPrefix] || []) {
102+
if (u._locale.code === defaultLocale) {
103+
alternatives.push({
105104
href: u.loc,
106-
hreflang: u._locale._hreflang || defaultLocale,
105+
hreflang: 'x-default',
107106
})
108-
return entries
107+
}
108+
alternatives.push({
109+
href: u.loc,
110+
hreflang: u._locale._hreflang || defaultLocale,
109111
})
110-
.flat()
111-
.filter(Boolean) as AlternativeEntry[]
112+
}
112113
if (alternatives.length)
113114
e.alternatives = alternatives
114115
}
@@ -135,7 +136,7 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU
135136
}
136137
else {
137138
// Find page mapping with support for dynamic routes
138-
const pageMatch = hasPages ? findPageMapping(e._pathWithoutPrefix, autoI18n.pages!) : null
139+
const pageMatch = hasPages ? findPageMapping(e._pathWithoutPrefix, autoI18n.pages!, sortedPageKeys) : null
139140
const pathSearch = e._path?.search || ''
140141
const pathWithoutPrefix = e._pathWithoutPrefix
141142

@@ -191,7 +192,7 @@ export function resolveSitemapEntries(sitemap: SitemapDefinition, urls: SitemapU
191192
href = joinURL('/', code, pathWithoutPrefix)
192193
}
193194

194-
if (!filterPath(href))
195+
if (filterPath && !filterPath(href))
195196
continue
196197
alternatives.push({
197198
hreflang: locale._hreflang,
@@ -283,11 +284,18 @@ export async function buildResolvedSitemapUrls(
283284
const localeSitemapKeys = isI18nMapped && autoI18n ? autoI18n.locales.map(l => l._sitemap) : []
284285
if (isMultiSitemap) {
285286
const sitemapNames = Object.keys(sitemaps).filter(k => k !== 'index')
287+
const validSitemapNames = new Set(sitemapNames)
288+
if (isI18nMapped) {
289+
for (const name of sitemapNames) {
290+
const localeKey = resolveI18nSitemapLocaleKey(name, localeSitemapKeys)
291+
if (localeKey)
292+
validSitemapNames.add(localeKey)
293+
}
294+
}
286295
// @ts-expect-error loose typing
287296
const warnedSitemaps = nitro?._sitemapWarnedSitemaps || new Set<string>()
288297
for (const e of enhancedUrls) {
289-
const hasMatchingSitemap = typeof e._sitemap === 'string'
290-
&& (sitemapNames.includes(e._sitemap) || (isI18nMapped && sitemapNames.some(name => resolveI18nSitemapLocaleKey(name, localeSitemapKeys) === e._sitemap)))
298+
const hasMatchingSitemap = typeof e._sitemap === 'string' && validSitemapNames.has(e._sitemap)
291299
if (typeof e._sitemap === 'string' && !hasMatchingSitemap) {
292300
if (!warnedSitemaps.has(e._sitemap)) {
293301
warnedSitemaps.add(e._sitemap)
@@ -301,6 +309,7 @@ export async function buildResolvedSitemapUrls(
301309
}
302310
}
303311

312+
const matchedLocaleSitemap = isI18nMapped ? resolveI18nSitemapLocaleKey(matchName, localeSitemapKeys) : null
304313
const filteredUrls = enhancedUrls.filter((e) => {
305314
if (e._sitemap === false)
306315
return false
@@ -313,7 +322,7 @@ export async function buildResolvedSitemapUrls(
313322
// back to its locale key (longest match) so prefix-sharing locales don't collide,
314323
// e.g. a `zh` URL must not land in the `zh-Hant` sitemap.
315324
if (isI18nMapped)
316-
return e._sitemap === resolveI18nSitemapLocaleKey(matchName, localeSitemapKeys)
325+
return e._sitemap === matchedLocaleSitemap
317326
return false
318327
}
319328
return true

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ function buildUrlXml(url: ResolvedSitemapUrl, NL: string, I1: string, I2: string
3131
if (url.alternatives) {
3232
for (const alt of url.alternatives) {
3333
let attrs = ''
34-
for (const [k, v] of Object.entries(alt)) attrs += ` ${k}="${xmlEscape(String(v))}"`
34+
for (const k in alt) {
35+
if (Object.hasOwn(alt, k))
36+
attrs += ` ${k}="${xmlEscape(String(alt[k as keyof typeof alt]))}"`
37+
}
3538
xml += `${I2}<xhtml:link rel="alternate"${attrs} />${NL}`
3639
}
3740
}

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

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ async function buildSitemapXml(event: H3Event, definition: SitemapDefinition, re
7575

7676
const routeRuleMatcher = createNitroRouteRuleMatcher()
7777
const { autoI18n } = runtimeConfig
78+
const localeCodes = autoI18n?.locales && autoI18n.strategy !== 'no_prefix'
79+
? new Set(autoI18n.locales.map(l => l.code))
80+
: undefined
7881

7982
// Process in place to avoid creating intermediate arrays
8083
let validCount = 0
@@ -89,8 +92,8 @@ async function buildSitemapXml(event: H3Event, definition: SitemapDefinition, re
8992
let routeRules = routeRuleMatcher(path)
9093

9194
// Apply top-level path without prefix
92-
if (autoI18n?.locales && autoI18n?.strategy !== 'no_prefix') {
93-
const match = splitForLocales(path, autoI18n.locales.map(l => l.code))
95+
if (localeCodes) {
96+
const match = splitForLocales(path, localeCodes)
9497
const pathWithoutPrefix = match[1]
9598
if (pathWithoutPrefix && pathWithoutPrefix !== path)
9699
routeRules = defu(routeRules, routeRuleMatcher(pathWithoutPrefix))
@@ -102,8 +105,16 @@ async function buildSitemapXml(event: H3Event, definition: SitemapDefinition, re
102105
if (typeof routeRules.robots !== 'undefined' && !routeRules.robots)
103106
continue
104107

105-
const hasRobotsDisabled = Object.entries(routeRules.headers || {})
106-
.some(([name, value]) => name.toLowerCase() === 'x-robots-tag' && value.toLowerCase().includes('noindex'))
108+
let hasRobotsDisabled = false
109+
const headers = routeRules.headers
110+
if (headers) {
111+
for (const name in headers) {
112+
if (name.toLowerCase() === 'x-robots-tag' && headers[name]!.toLowerCase().includes('noindex')) {
113+
hasRobotsDisabled = true
114+
break
115+
}
116+
}
117+
}
107118

108119
if (routeRules.redirect || hasRobotsDisabled)
109120
continue
@@ -129,14 +140,34 @@ async function buildSitemapXml(event: H3Event, definition: SitemapDefinition, re
129140
// we need to normalize any new urls otherwise they won't appear in the final sitemap
130141
// Note this is risky and users should be using the sitemap:input hook for additions
131142
if (resolvedCtx.urls.length !== locSize) {
132-
resolvedCtx.urls = resolvedCtx.urls.map(e => preNormalizeEntry(e, resolvers))
143+
for (let i = 0; i < resolvedCtx.urls.length; i++)
144+
resolvedCtx.urls[i] = preNormalizeEntry(resolvedCtx.urls[i]!, resolvers)
133145
}
134146

135147
const maybeSort = (urls: ResolvedSitemapUrl[]) => runtimeConfig.sortEntries ? sortInPlace(urls) : urls
136148
// final urls
137-
const defaults = definition.defaults || {}
138-
const normalizedPreDedupe = resolvedCtx.urls.map(e => normaliseEntry(e, defaults, resolvers))
139-
const urls = maybeSort(mergeOnKey(normalizedPreDedupe, '_key').map(e => normaliseEntry(e, defaults, resolvers)))
149+
const defaults = definition.defaults
150+
const normalizedPreDedupe = resolvedCtx.urls
151+
// Repeated CMS/default timestamps are common and expensive to parse. Sample the leading entries
152+
// so unique timestamp feeds avoid paying cache bookkeeping on every URL.
153+
const firstLastmod = normalizedPreDedupe[0]?.lastmod ?? defaults?.lastmod
154+
let cacheLastmod = normalizedPreDedupe.length > 1 && !!firstLastmod
155+
for (let i = 1; cacheLastmod && i < Math.min(normalizedPreDedupe.length, 8); i++)
156+
cacheLastmod = (normalizedPreDedupe[i]!.lastmod ?? defaults?.lastmod) === firstLastmod
157+
const normaliseCache = cacheLastmod ? {} : undefined
158+
for (let i = 0; i < normalizedPreDedupe.length; i++)
159+
normalizedPreDedupe[i] = normaliseEntry(normalizedPreDedupe[i]!, defaults, resolvers, normaliseCache)
160+
const duplicateKeys = new Set<string>()
161+
const urls = mergeOnKey(normalizedPreDedupe, '_key', key => duplicateKeys.add(key))
162+
// A merge can introduce duplicate alternatives/images/videos. Unique URLs are already fully
163+
// normalized, so avoid cloning and resolving every nested entry a second time.
164+
if (duplicateKeys.size) {
165+
for (let i = 0; i < urls.length; i++) {
166+
if (duplicateKeys.has(urls[i]!._key))
167+
urls[i] = normaliseEntry(urls[i]!, defaults, resolvers, normaliseCache)
168+
}
169+
}
170+
maybeSort(urls)
140171

141172
// Check if this is a chunk request that would be empty
142173
if (definition._isChunking && definition.sitemapName.includes('-')) {

0 commit comments

Comments
 (0)