@nuxtjs/sitemap 8.4.0 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "nuxt": ">=3.9.0"
5
5
  },
6
6
  "configKey": "sitemap",
7
- "version": "8.4.0",
7
+ "version": "8.5.0",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "1.0.3",
10
10
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -8,9 +8,12 @@ import { dirname, extname } from 'pathe';
8
8
  import { readPackageJSON } from 'pkg-types';
9
9
  import { withBase, withHttps, withTrailingSlash, withoutLeadingSlash, joinURL, withLeadingSlash } from 'ufo';
10
10
  import { setupDevToolsUI as setupDevToolsUI$1 } from 'nuxtseo-shared/devtools';
11
+ import { once } from 'node:events';
11
12
  import { readFileSync, statSync } from 'node:fs';
12
13
  import { rm, mkdir, writeFile } from 'node:fs/promises';
13
- import { join } from 'node:path';
14
+ import { join, resolve } from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { Worker, MessageChannel } from 'node:worker_threads';
14
17
  import { colors } from 'consola/utils';
15
18
  import { splitForLocales, createPathFilter } from '../dist/runtime/utils-pure.js';
16
19
  import { p as parseHtmlExtractSitemapMeta } from './shared/sitemap.BoMnWHOt.mjs';
@@ -75,6 +78,10 @@ export async function readSourcesFromFilesystem(filename) {
75
78
  `;
76
79
  });
77
80
  nuxt.hooks.hook("nitro:init", async (nitro) => {
81
+ let prerendererNitro = nitro;
82
+ nitro.hooks.hook("prerender:init", (prerenderer) => {
83
+ prerendererNitro = prerenderer;
84
+ });
78
85
  await Promise.all([
79
86
  rm(join(runtimeAssetsPath, "global-sources.json"), { force: true }),
80
87
  rm(join(runtimeAssetsPath, "child-sources.json"), { force: true })
@@ -128,12 +135,101 @@ export async function readSourcesFromFilesystem(filename) {
128
135
  await writeFile(join(runtimeAssetsPath, "global-sources.json"), JSON.stringify(globalSources));
129
136
  await writeFile(join(runtimeAssetsPath, "child-sources.json"), JSON.stringify(childSources));
130
137
  const sitemapEntry = options.isMultiSitemap ? "/sitemap_index.xml" : `/${Object.keys(options.sitemaps)[0]}`;
131
- const sitemaps = await prerenderSitemapsFromEntry(nitro, sitemapEntry);
132
- await nuxt.hooks.callHook("sitemap:prerender:done", { options, sitemaps });
138
+ const prerenderServer = await loadPrerenderServer(prerendererNitro);
139
+ await prerenderSitemapsFromEntry(nitro, prerenderServer.fetch, sitemapEntry).then((sitemaps) => nuxt.hooks.callHook("sitemap:prerender:done", { options, sitemaps })).finally(prerenderServer.close);
133
140
  });
134
141
  });
135
142
  }
136
- async function prerenderSitemapsFromEntry(nitro, entry) {
143
+ const PrerenderWorkerCode = `
144
+ const { parentPort, workerData } = require('node:worker_threads')
145
+
146
+ ;(async () => {
147
+ const serverEntry = await import(workerData.entry)
148
+ const server = serverEntry.default
149
+ const localFetch = serverEntry.localFetch
150
+ const fetch = typeof server?.fetch === 'function'
151
+ ? (input, headers) => server.fetch(new Request(new URL(input, 'http://localhost'), { headers }))
152
+ : typeof localFetch === 'function'
153
+ // older nitropack exposes an ofetch instance: a bare call returns parsed data, .raw returns the response
154
+ ? (input, headers) => (localFetch.raw ?? localFetch)(input, { headers })
155
+ : undefined
156
+ const close = typeof server?.close === 'function'
157
+ ? () => server.close()
158
+ : typeof serverEntry.closePrerenderer === 'function'
159
+ ? () => serverEntry.closePrerenderer()
160
+ : async () => {}
161
+
162
+ parentPort.postMessage(fetch ? { _tag: 'Ready' } : { _tag: 'Err', message: 'Nitro prerender server does not expose a fetch handler' })
163
+ parentPort.on('message', ({ _tag, input, headers, port }) => {
164
+ const task = _tag === 'Fetch'
165
+ ? fetch(input, headers).then(async response => ({
166
+ _tag: 'Ok',
167
+ status: response.status,
168
+ statusText: response.statusText,
169
+ headers: [...response.headers],
170
+ body: await response.arrayBuffer(),
171
+ }))
172
+ : close().then(() => ({ _tag: 'Ok' }))
173
+ task.catch(error => ({ _tag: 'Err', message: error instanceof Error ? error.message : String(error) }))
174
+ .then(result => port.postMessage(result, result.body ? [result.body] : []))
175
+ })
176
+ })().catch(error => parentPort.postMessage({ _tag: 'Err', message: error instanceof Error ? error.message : String(error) }))
177
+ `;
178
+ function raceWithTimeout(promises, ms, message) {
179
+ let timer;
180
+ const timeout = new Promise((_, reject) => {
181
+ timer = setTimeout(() => reject(new Error(message)), ms);
182
+ });
183
+ return Promise.race([...promises, timeout]).finally(() => clearTimeout(timer));
184
+ }
185
+ async function loadPrerenderServer(nitro) {
186
+ const entryFileNames = nitro.options.rollupConfig?.output?.entryFileNames;
187
+ const serverFilename = typeof entryFileNames === "string" ? entryFileNames : "index.mjs";
188
+ const worker = new Worker(PrerenderWorkerCode, {
189
+ eval: true,
190
+ workerData: { entry: pathToFileURL(resolve(nitro.options.output.serverDir, serverFilename)).href }
191
+ });
192
+ const workerDead = new Promise((_, reject) => {
193
+ worker.once("exit", (code) => reject(new Error(`The Nitro prerender server worker exited unexpectedly (code ${code})`)));
194
+ worker.once("error", (error) => reject(error));
195
+ });
196
+ workerDead.catch(() => {
197
+ });
198
+ try {
199
+ const [ready] = await raceWithTimeout([once(worker, "message"), workerDead], 6e4, "The Nitro prerender server did not become ready in time");
200
+ if (ready._tag === "Err")
201
+ throw new Error(ready.message);
202
+ } catch (error) {
203
+ await worker.terminate();
204
+ throw error;
205
+ }
206
+ const callWorker = async (message) => {
207
+ const { port1, port2 } = new MessageChannel();
208
+ worker.postMessage({ ...message, port: port2 }, [port2]);
209
+ try {
210
+ const [result] = await Promise.race([once(port1, "message"), workerDead]);
211
+ if (result._tag === "Err")
212
+ throw new Error(result.message);
213
+ return result;
214
+ } finally {
215
+ port1.close();
216
+ }
217
+ };
218
+ return {
219
+ async fetch(input, headers) {
220
+ const { _tag, body, ...responseInit } = await callWorker({ _tag: "Fetch", input, headers });
221
+ return new Response(body, responseInit);
222
+ },
223
+ async close() {
224
+ try {
225
+ await raceWithTimeout([callWorker({ _tag: "Close" })], 3e3, "The Nitro prerender server did not close in time");
226
+ } catch {
227
+ }
228
+ await worker.terminate();
229
+ }
230
+ };
231
+ }
232
+ async function prerenderSitemapsFromEntry(nitro, fetch, entry) {
137
233
  const sitemaps = [];
138
234
  const queue = [entry];
139
235
  const processed = /* @__PURE__ */ new Set();
@@ -142,7 +238,7 @@ async function prerenderSitemapsFromEntry(nitro, entry) {
142
238
  if (processed.has(route))
143
239
  continue;
144
240
  processed.add(route);
145
- const { filePath, prerenderUrls } = await prerenderRoute(nitro, route);
241
+ const { filePath, prerenderUrls } = await prerenderRoute(nitro, fetch, route);
146
242
  sitemaps.push({
147
243
  name: route,
148
244
  get content() {
@@ -153,27 +249,19 @@ async function prerenderSitemapsFromEntry(nitro, entry) {
153
249
  }
154
250
  return sitemaps;
155
251
  }
156
- async function prerenderRoute(nitro, route) {
252
+ async function prerenderRoute(nitro, fetch, route) {
157
253
  const start = Date.now();
158
254
  const _route = { route, fileName: route };
159
255
  const encodedRoute = encodeURI(route);
160
256
  const fetchUrl = withBase(encodedRoute, nitro.options.baseURL);
161
- const res = await globalThis.$fetch.raw(
162
- fetchUrl,
163
- {
164
- headers: { "x-nitro-prerender": encodedRoute },
165
- retry: nitro.options.prerender.retry,
166
- retryDelay: nitro.options.prerender.retryDelay
167
- }
168
- );
257
+ const res = await fetch(fetchUrl, { "x-nitro-prerender": encodedRoute });
258
+ if (!res.ok)
259
+ throw new Error(`Failed to prerender '${fetchUrl}': ${res.status} ${res.statusText}`);
169
260
  const header = res.headers.get("x-nitro-prerender") || "";
170
261
  const prerenderUrls = header.split(",").map((i) => decodeURIComponent(i.trim())).filter(Boolean);
171
262
  const filePath = join(nitro.options.output.publicDir, _route.fileName);
172
263
  await mkdir(dirname(filePath), { recursive: true });
173
- const data = res._data;
174
- if (data === void 0)
175
- throw new Error(`No data returned from '${fetchUrl}'`);
176
- const content = filePath.endsWith("json") || typeof data === "object" ? JSON.stringify(data) : data;
264
+ const content = await res.text();
177
265
  await writeFile(filePath, content, "utf8");
178
266
  _route.generateTimeMS = Date.now() - start;
179
267
  nitro._prerenderedRoutes.push(_route);
@@ -192,7 +280,8 @@ function registerTypeTemplates(nitroCompatibility) {
192
280
  'sitemap:input': (ctx: SitemapInputCtx<${nitroCompatibility.eventType}>) => void | Promise<void>
193
281
  'sitemap:resolved': (ctx: SitemapRenderCtx<${nitroCompatibility.eventType}>) => void | Promise<void>
194
282
  'sitemap:output': (ctx: SitemapOutputHookCtx<${nitroCompatibility.eventType}>) => void | Promise<void>
195
- 'sitemap:sources': (ctx: SitemapSourcesHookCtx<${nitroCompatibility.eventType}>) => void | Promise<void>`;
283
+ 'sitemap:sources': (ctx: SitemapSourcesHookCtx<${nitroCompatibility.eventType}>) => void | Promise<void>
284
+ 'sitemap:sitemaps-resolved': (ctx: SitemapsResolvedCtx<${nitroCompatibility.eventType}>) => void | Promise<void>`;
196
285
  const nitroTypes = renderNitroTypeAugmentations(nitroCompatibility, {
197
286
  nitroInterfaces: {
198
287
  PrerenderRoute: "_sitemap?: SitemapUrl"
@@ -203,7 +292,7 @@ function registerTypeTemplates(nitroCompatibility) {
203
292
  });
204
293
  return `// Generated by @nuxtjs/sitemap
205
294
  /// <reference path="./nuxt-sitemap-virtual.d.ts" />
206
- import type { SitemapUrl, SitemapItemDefaults, SitemapIndexRenderCtx, SitemapInputCtx, SitemapRenderCtx, SitemapOutputHookCtx, SitemapSourcesHookCtx } from '@nuxtjs/sitemap'
295
+ import type { SitemapUrl, SitemapItemDefaults, SitemapIndexRenderCtx, SitemapInputCtx, SitemapRenderCtx, SitemapOutputHookCtx, SitemapSourcesHookCtx, SitemapsResolvedCtx } from '@nuxtjs/sitemap'
207
296
 
208
297
  ${nitroTypes}
209
298
 
@@ -6,7 +6,7 @@ import {
6
6
  globalSitemapSources,
7
7
  resolveSitemapSources
8
8
  } from "../../sitemap/urlset/sources.js";
9
- import { useSitemapRuntimeConfig } from "../../utils.js";
9
+ import { useResolvedSitemapRuntimeConfig } from "../../utils.js";
10
10
  function attachUrlWarnings(sources) {
11
11
  for (const source of sources) {
12
12
  if (!source.urls?.length)
@@ -26,7 +26,7 @@ function attachUrlWarnings(sources) {
26
26
  return sources;
27
27
  }
28
28
  export default defineEventHandler(async (e) => {
29
- const _runtimeConfig = useSitemapRuntimeConfig();
29
+ const _runtimeConfig = await useResolvedSitemapRuntimeConfig(e);
30
30
  const siteConfig = getSiteConfig(e);
31
31
  const { sitemaps: _sitemaps } = _runtimeConfig;
32
32
  const runtimeConfig = { ..._runtimeConfig };
@@ -3,6 +3,7 @@ import { getHeader } from "#nuxtseo/h3";
3
3
  import { defineCachedFunction } from "#nuxtseo/nitro";
4
4
  import staticConfig from "#sitemap-virtual/static-config.mjs";
5
5
  import { normaliseDate } from "../urlset/normalise.js";
6
+ import { parseChunkInfo } from "../utils/chunk.js";
6
7
  import { getResolvedSitemapUrls } from "./sitemap.js";
7
8
  const SERVER_CACHE_MAX_AGE = staticConfig.cacheMaxAgeSeconds || 60 * 10;
8
9
  const buildSitemapIndexCached = defineCachedFunction(
@@ -43,7 +44,9 @@ async function buildSitemapIndexInternal(resolvers, runtimeConfig, nitro) {
43
44
  sitemapConfig._isChunking = true;
44
45
  sitemapConfig._chunkSize = sitemapConfig.chunkSize || (typeof sitemapConfig.chunks === "number" ? sitemapConfig.chunks : defaultSitemapsChunkSize || 1e3);
45
46
  } else {
46
- nonChunkedNames.push(sitemapName);
47
+ const chunkInfo = parseChunkInfo(sitemapName, sitemaps, defaultSitemapsChunkSize || void 0);
48
+ if (!chunkInfo.isChunked)
49
+ nonChunkedNames.push(sitemapName);
47
50
  }
48
51
  }
49
52
  const indexLastmod = autoLastmod ? normaliseDate(/* @__PURE__ */ new Date()) : void 0;
@@ -50,7 +50,7 @@ export async function buildResolvedSitemapUrls(effectiveSitemap, matchName, isCh
50
50
  if (typeof e._sitemap === "string" && !hasMatchingSitemap) {
51
51
  if (!warnedSitemaps.has(e._sitemap)) {
52
52
  warnedSitemaps.add(e._sitemap);
53
- logger.error(`Sitemap \`${e._sitemap}\` not found in sitemap config. Available sitemaps: ${sitemapNames.join(", ")}. Entry \`${e.loc}\` will be omitted.`);
53
+ logger.error(`Sitemap \`${e._sitemap}\` not found in sitemap config. Available sitemaps: ${sitemapNames.join(", ")}. Either add it to the sitemap config or register it with the sitemap:sitemaps-resolved hook. Entry \`${e.loc}\` will be omitted.`);
54
54
  }
55
55
  }
56
56
  }
@@ -88,7 +88,13 @@ export const buildResolvedSitemapUrlsCached = defineCachedFunction(
88
88
  const proto = getHeader(event, "x-forwarded-proto") || "https";
89
89
  return `resolved-${isChunked ? "chunked-" : ""}${matchName}-${proto}-${host}`;
90
90
  },
91
- swr: true
91
+ swr: true,
92
+ // A build with failed sources is never cached: one outage must not pin an empty sitemap
93
+ // for a whole cache window. The next request retries the sources instead.
94
+ validate: (entry) => {
95
+ const value = entry.value;
96
+ return value !== void 0 && !value.failedSources?.length;
97
+ }
92
98
  }
93
99
  );
94
100
  export async function getResolvedSitemapUrls(effectiveSitemap, matchName, isChunked, resolvers, runtimeConfig, nitro) {
@@ -1,20 +1,20 @@
1
1
  import { joinURL, withBase, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from "ufo";
2
2
  import { appendHeader, createError, getRequestURL, getRouterParam, sendRedirect } from "#nuxtseo/h3";
3
3
  import { useNitroApp, useRuntimeConfig } from "#nuxtseo/nitro";
4
- import { useSitemapRuntimeConfig } from "../utils.js";
4
+ import { useResolvedSitemapRuntimeConfig } from "../utils.js";
5
5
  import { urlsToIndexXml, urlsToIndexXmlStream } from "./builder/index-xml.js";
6
6
  import { buildSitemapIndex } from "./builder/sitemap-index.js";
7
7
  import { createSitemap, renderSitemapOutput, setSitemapResponseHeaders, useNitroUrlResolvers } from "./nitro.js";
8
8
  import { getSitemapConfig, parseChunkInfo } from "./utils/chunk.js";
9
9
  export async function sitemapXmlEventHandler(e) {
10
- const runtimeConfig = useSitemapRuntimeConfig();
10
+ const runtimeConfig = await useResolvedSitemapRuntimeConfig(e);
11
11
  const { sitemaps } = runtimeConfig;
12
12
  if ("index" in sitemaps)
13
13
  return sendRedirect(e, withBase("/sitemap_index.xml", useRuntimeConfig().app.baseURL), import.meta.dev ? 302 : 301);
14
14
  return createSitemap(e, Object.values(sitemaps)[0], runtimeConfig);
15
15
  }
16
16
  export async function sitemapIndexXmlEventHandler(e) {
17
- const runtimeConfig = useSitemapRuntimeConfig();
17
+ const runtimeConfig = await useResolvedSitemapRuntimeConfig(e);
18
18
  const nitro = useNitroApp();
19
19
  const resolvers = useNitroUrlResolvers(e);
20
20
  const { entries: sitemaps, failedSources } = await buildSitemapIndex(resolvers, runtimeConfig, nitro);
@@ -44,7 +44,7 @@ export async function sitemapChildXmlEventHandler(e) {
44
44
  const pathname = getRequestURL(e).pathname;
45
45
  if (!pathname.endsWith(".xml"))
46
46
  return;
47
- const runtimeConfig = useSitemapRuntimeConfig(e);
47
+ const runtimeConfig = await useResolvedSitemapRuntimeConfig(e);
48
48
  const { sitemaps } = runtimeConfig;
49
49
  let sitemapName = getRouterParam(e, "sitemap");
50
50
  if (!sitemapName) {
@@ -66,7 +66,7 @@ export async function sitemapChildXmlEventHandler(e) {
66
66
  sitemapName = withoutLeadingSlash(withoutTrailingSlash(sitemapName));
67
67
  const chunkInfo = parseChunkInfo(sitemapName, sitemaps, runtimeConfig.defaultSitemapsChunkSize);
68
68
  const isAutoChunked = typeof sitemaps.chunks !== "undefined" && !Number.isNaN(Number(sitemapName));
69
- const sitemapExists = sitemapName in sitemaps || chunkInfo.baseSitemapName in sitemaps || isAutoChunked;
69
+ const sitemapExists = Object.hasOwn(sitemaps, sitemapName) || Object.hasOwn(sitemaps, chunkInfo.baseSitemapName) || isAutoChunked;
70
70
  if (!sitemapExists)
71
71
  throw createError({ statusCode: 404, message: `Sitemap "${sitemapName}" not found.` });
72
72
  if (chunkInfo.isChunked && chunkInfo.chunkIndex !== void 0) {
@@ -2,8 +2,9 @@ import { defu } from "defu";
2
2
  import { $fetch } from "ofetch";
3
3
  import { collectSitemap } from "sitemapd/parse";
4
4
  import { parseURL } from "ufo";
5
- import { getRequestHost } from "#nuxtseo/h3";
6
- import { fetchWithEvent } from "#nuxtseo/nitro";
5
+ import { getHeader, getRequestHost } from "#nuxtseo/h3";
6
+ import { defineCachedFunction, fetchWithEvent } from "#nuxtseo/nitro";
7
+ import staticConfig from "#sitemap-virtual/static-config.mjs";
7
8
  import { logger } from "../../../utils-pure.js";
8
9
  const changeFrequencies = /* @__PURE__ */ new Set([
9
10
  "always",
@@ -69,10 +70,78 @@ async function tryFetchWithFallback(url, options, event) {
69
70
  }
70
71
  return event ? await fetchWithEvent(event, url, options) : await globalThis.$fetch(url, options);
71
72
  }
73
+ const SOURCE_FETCH_MEMO_KEY = "_sitemapSourceFetches";
74
+ const SERVER_CACHE_MAX_AGE = staticConfig.cacheMaxAgeSeconds || 60 * 10;
75
+ const prerenderSourceFetches = import.meta.prerender ? /* @__PURE__ */ new Map() : void 0;
76
+ function useSourceFetchMemo(event) {
77
+ if (import.meta.prerender)
78
+ return prerenderSourceFetches;
79
+ const context = event?.context;
80
+ if (!context)
81
+ return void 0;
82
+ const existing = context[SOURCE_FETCH_MEMO_KEY];
83
+ if (existing)
84
+ return existing;
85
+ const memo = /* @__PURE__ */ new Map();
86
+ context[SOURCE_FETCH_MEMO_KEY] = memo;
87
+ return memo;
88
+ }
89
+ function hashCacheKey(key) {
90
+ let h1 = 3735928559;
91
+ let h2 = 1103547991;
92
+ for (let i = 0; i < key.length; i++) {
93
+ const ch = key.charCodeAt(i);
94
+ h1 = Math.imul(h1 ^ ch, 2654435761);
95
+ h2 = Math.imul(h2 ^ ch, 1597334677);
96
+ }
97
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
98
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
99
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
100
+ }
101
+ const fetchSourceUrlsCached = defineCachedFunction(
102
+ (event, _key, url, options) => fetchSourceUrls(url, options, event),
103
+ {
104
+ name: "sitemap:source-urls",
105
+ group: "sitemap",
106
+ base: "sitemap",
107
+ maxAge: SERVER_CACHE_MAX_AGE,
108
+ getKey: (event, key) => {
109
+ const host = getHeader(event, "host") || getHeader(event, "x-forwarded-host") || "";
110
+ const proto = getHeader(event, "x-forwarded-proto") || "https";
111
+ return `source-${proto}-${host}-${hashCacheKey(key)}`;
112
+ },
113
+ swr: true,
114
+ // A failed fetch must never be served again, otherwise one outage empties the sitemap for a
115
+ // whole cache window.
116
+ validate: (entry) => {
117
+ const value = entry.value;
118
+ return value !== void 0 && !value._isFailure;
119
+ }
120
+ }
121
+ );
122
+ function isSourceCacheEnabled() {
123
+ if (import.meta.dev || import.meta.prerender)
124
+ return false;
125
+ const cacheMaxAgeSeconds = staticConfig.cacheMaxAgeSeconds;
126
+ return typeof cacheMaxAgeSeconds === "number" && cacheMaxAgeSeconds > 0;
127
+ }
72
128
  export async function fetchDataSource(input, event) {
73
129
  const context = typeof input.context === "string" ? { name: input.context } : input.context || { name: "fetch" };
74
130
  const url = typeof input.fetch === "string" ? input.fetch : input.fetch[0];
75
131
  const options = typeof input.fetch === "string" ? {} : input.fetch[1];
132
+ const memo = useSourceFetchMemo(event);
133
+ const key = `${url}::${JSON.stringify(options || {})}`;
134
+ let request = memo?.get(key);
135
+ if (!request) {
136
+ request = event && isSourceCacheEnabled() ? fetchSourceUrlsCached(event, key, url, options) : fetchSourceUrls(url, options, event);
137
+ memo?.set(key, request);
138
+ }
139
+ const result = await request;
140
+ if (result._isFailure)
141
+ memo?.delete(key);
142
+ return { ...input, context, ...result };
143
+ }
144
+ async function fetchSourceUrls(url, options, event) {
76
145
  const start = Date.now();
77
146
  const isExternalUrl = !url.startsWith("/");
78
147
  const timeout = isExternalUrl ? 1e4 : options.timeout || 5e3;
@@ -113,11 +182,12 @@ export async function fetchDataSource(input, event) {
113
182
  const timeTakenMs = Date.now() - start;
114
183
  if (isMaybeErrorResponse) {
115
184
  return {
116
- ...input,
117
- context,
118
185
  urls: [],
119
186
  timeTakenMs,
120
- error: "Received HTML response instead of JSON"
187
+ error: "Received HTML response instead of JSON",
188
+ // An HTML page is usually an outage or an auth wall, both transient. Treat it like a
189
+ // failed fetch so the empty result is never cached.
190
+ _isFailure: true
121
191
  };
122
192
  }
123
193
  let urls = [];
@@ -133,8 +203,6 @@ export async function fetchDataSource(input, event) {
133
203
  urls = res.urls || res;
134
204
  }
135
205
  return {
136
- ...input,
137
- context,
138
206
  timeTakenMs,
139
207
  urls
140
208
  };
@@ -154,8 +222,6 @@ export async function fetchDataSource(input, event) {
154
222
  logger.error("Failed to fetch source.", { url, error: error.message });
155
223
  }
156
224
  return {
157
- ...input,
158
- context,
159
225
  urls: [],
160
226
  error: error.message,
161
227
  _isFailure: true
@@ -179,6 +245,15 @@ export async function globalSitemapSources() {
179
245
  return [...m.sources];
180
246
  }
181
247
  export async function childSitemapSources(definition) {
248
+ if (definition?.sources?.length)
249
+ return [...definition.sources];
250
+ if (definition?.urls) {
251
+ const urls = typeof definition.urls === "function" ? await definition.urls() : definition.urls;
252
+ return [{
253
+ context: { name: `sitemaps:${definition.sitemapName}:urls`, description: "Set with the sitemap definition `urls`." },
254
+ urls
255
+ }];
256
+ }
182
257
  if (!definition?._hasSourceChunk)
183
258
  return [];
184
259
  if (import.meta.prerender) {
@@ -2,3 +2,4 @@ import type { H3Event } from '#nuxtseo/h3';
2
2
  import type { ModuleRuntimeConfig } from '../types.js';
3
3
  export * from '../utils-pure.js';
4
4
  export declare function useSitemapRuntimeConfig(e?: H3Event): ModuleRuntimeConfig;
5
+ export declare function useResolvedSitemapRuntimeConfig(e: H3Event): Promise<ModuleRuntimeConfig>;
@@ -1,18 +1,74 @@
1
- import { useRuntimeConfig } from "#nuxtseo/nitro";
1
+ import { getHeader } from "#nuxtseo/h3";
2
+ import { defineCachedFunction, useNitroApp, useRuntimeConfig } from "#nuxtseo/nitro";
2
3
  import staticConfig from "#sitemap-virtual/static-config.mjs";
3
4
  import { normalizeRuntimeFilters } from "../utils-pure.js";
4
5
  export * from "../utils-pure.js";
5
- export function useSitemapRuntimeConfig(e) {
6
- const sitemaps = Object.fromEntries(
6
+ const SERVER_CACHE_MAX_AGE = staticConfig.cacheMaxAgeSeconds || 60 * 10;
7
+ function dynamicRuntimeConfig(e) {
8
+ return useRuntimeConfig(e).sitemap;
9
+ }
10
+ function copyStaticSitemaps() {
11
+ return Object.fromEntries(
7
12
  Object.entries(staticConfig.sitemaps).map(([name, sitemap]) => [name, {
8
13
  ...sitemap,
9
14
  include: normalizeRuntimeFilters("include" in sitemap ? sitemap.include : void 0),
10
15
  exclude: normalizeRuntimeFilters("exclude" in sitemap ? sitemap.exclude : void 0)
11
16
  }])
12
17
  );
18
+ }
19
+ export function useSitemapRuntimeConfig(e) {
20
+ return Object.freeze({
21
+ ...staticConfig,
22
+ sitemaps: copyStaticSitemaps(),
23
+ ...dynamicRuntimeConfig(e)
24
+ });
25
+ }
26
+ function serializeFilters(filters) {
27
+ if (!filters?.length)
28
+ return void 0;
29
+ return filters.map((f) => {
30
+ if (f instanceof RegExp)
31
+ return { regex: `/${f.source}/${f.flags}` };
32
+ return f;
33
+ });
34
+ }
35
+ async function resolveSitemapSitemaps(e, nitro) {
36
+ const ctx = { sitemaps: copyStaticSitemaps(), event: e };
37
+ await nitro.hooks.callHook("sitemap:sitemaps-resolved", ctx);
38
+ const sitemaps = { ...ctx.sitemaps };
39
+ for (const name of Object.keys(sitemaps)) {
40
+ const sitemap = { ...sitemaps[name] };
41
+ if (typeof sitemap.urls === "function")
42
+ sitemap.urls = await sitemap.urls();
43
+ sitemap.include = serializeFilters(sitemap.include);
44
+ sitemap.exclude = serializeFilters(sitemap.exclude);
45
+ sitemaps[name] = sitemap;
46
+ }
47
+ return sitemaps;
48
+ }
49
+ const resolveSitemapSitemapsCached = defineCachedFunction(
50
+ resolveSitemapSitemaps,
51
+ {
52
+ name: "sitemap:runtime-sitemaps",
53
+ group: "sitemap",
54
+ maxAge: SERVER_CACHE_MAX_AGE,
55
+ base: "sitemap",
56
+ // nitro calls getKey with the full fn args (event, nitro)
57
+ getKey: (e) => {
58
+ const host = e && (getHeader(e, "host") || getHeader(e, "x-forwarded-host")) || "";
59
+ const proto = e && getHeader(e, "x-forwarded-proto") || "https";
60
+ return `runtime-sitemaps-${proto}-${host}`;
61
+ },
62
+ swr: true
63
+ }
64
+ );
65
+ export async function useResolvedSitemapRuntimeConfig(e) {
66
+ const maxAge = dynamicRuntimeConfig(e)?.cacheMaxAgeSeconds ?? staticConfig.cacheMaxAgeSeconds;
67
+ const shouldCache = !import.meta.dev && !import.meta.prerender && typeof maxAge === "number" && maxAge > 0;
68
+ const sitemaps = shouldCache ? await resolveSitemapSitemapsCached(e, useNitroApp()) : await resolveSitemapSitemaps(e, useNitroApp());
13
69
  return Object.freeze({
14
70
  ...staticConfig,
15
71
  sitemaps,
16
- ...useRuntimeConfig(e).sitemap
72
+ ...dynamicRuntimeConfig(e)
17
73
  });
18
74
  }
@@ -245,7 +245,7 @@ export interface ModuleRuntimeConfig extends Pick<ModuleOptions, 'sitemapsPathPr
245
245
  index?: Pick<SitemapDefinition, 'sitemapName' | '_route'> & {
246
246
  sitemaps: SitemapIndexEntry[];
247
247
  };
248
- } & Record<string, Omit<SitemapDefinition, 'urls'> & {
248
+ } & Record<string, SitemapDefinition & {
249
249
  _hasSourceChunk?: boolean;
250
250
  }>;
251
251
  autoI18n?: AutoI18nConfig;
@@ -398,6 +398,20 @@ export interface SitemapSourcesHookCtx<Event = H3Event> extends NitroBaseHook<Ev
398
398
  sitemapName: string;
399
399
  sources: SitemapSourceInput[];
400
400
  }
401
+ export interface SitemapsResolvedCtx<Event = H3Event> extends NitroBaseHook<Event> {
402
+ /**
403
+ * The sitemaps config about to be used to build the sitemap index and serve child
404
+ * sitemaps. Static definitions from nuxt.config are already present.
405
+ *
406
+ * Push definitions here to register sitemaps at runtime, for example when the data set
407
+ * grows while the server is running. Delete a key to remove its sitemap from the index
408
+ * and stop serving it. Registered sitemaps accept the same fields as nuxt.config
409
+ * definitions (`sources`, `urls`, `include`, `exclude`, `defaults`, `chunks`) and are
410
+ * served, listed in the sitemap index, and passed the `sitemap:sources` hook like
411
+ * static ones.
412
+ */
413
+ sitemaps: ModuleRuntimeConfig['sitemaps'];
414
+ }
401
415
  export type Changefreq = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
402
416
  export interface SitemapUrl {
403
417
  loc: string;
@@ -413,6 +427,10 @@ export interface SitemapUrl {
413
427
  images?: Array<ImageEntry>;
414
428
  videos?: Array<VideoEntry>;
415
429
  _i18nTransform?: boolean;
430
+ /**
431
+ * Route this URL to a specific sitemap. The name must exist in the sitemap config,
432
+ * either set in `nuxt.config` or registered with the `sitemap:sitemaps-resolved` hook.
433
+ */
416
434
  _sitemap?: string | false;
417
435
  /**
418
436
  * Mark the URL as already encoded.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nuxtjs/sitemap",
3
3
  "type": "module",
4
- "version": "8.4.0",
4
+ "version": "8.5.0",
5
5
  "description": "Powerfully flexible XML Sitemaps that integrate seamlessly, for Nuxt.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -73,7 +73,7 @@
73
73
  "@arethetypeswrong/cli": "^0.18.5",
74
74
  "@harlan-zw/comark-content": "^0.1.3",
75
75
  "@nuxt/content": "^3.15.2",
76
- "@nuxt/devtools-kit": "4.0.0-alpha.11",
76
+ "@nuxt/devtools-kit": "4.0.0-alpha.12",
77
77
  "@nuxt/module-builder": "^1.0.3",
78
78
  "@nuxt/test-utils": "^4.1.0",
79
79
  "@nuxt/ui": "^4.10.0",
@@ -84,7 +84,7 @@
84
84
  "bumpp": "^12.2.1",
85
85
  "eslint": "^10.8.1",
86
86
  "eslint-plugin-harlanzw": "^0.20.0",
87
- "happy-dom": "^20.11.2",
87
+ "happy-dom": "^20.11.6",
88
88
  "nuxt": "^4.5.2",
89
89
  "nuxt-i18n-micro": "^3.28.0",
90
90
  "nuxtseo-layer-devtools": "^5.3.14",
@@ -94,7 +94,7 @@
94
94
  "vitest": "^4.1.11",
95
95
  "vue-tsc": "^3.3.10",
96
96
  "zod": "^4.4.3",
97
- "@nuxtjs/sitemap": "8.4.0"
97
+ "@nuxtjs/sitemap": "8.5.0"
98
98
  },
99
99
  "scripts": {
100
100
  "lint": "eslint .",