@djangocfg/nextjs 2.1.570 → 2.1.571
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/i18n/routing.d.mts +2 -2
- package/dist/index.mjs +12 -12
- package/dist/index.mjs.map +1 -1
- package/dist/sitemap/index.d.mts +6 -4
- package/dist/sitemap/index.mjs +12 -12
- package/dist/sitemap/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/sitemap/README.md +15 -5
- package/src/sitemap/fetch.ts +21 -16
package/dist/sitemap/index.d.mts
CHANGED
|
@@ -288,11 +288,13 @@ declare function selectSources(index: SitemapIndex, select: SourceSelector | und
|
|
|
288
288
|
declare function tenantSources(tenant: string): (name: string) => boolean;
|
|
289
289
|
|
|
290
290
|
/**
|
|
291
|
-
* Sitemap fetch helpers
|
|
291
|
+
* Sitemap fetch helpers.
|
|
292
292
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
293
|
+
* During `next build` a failure becomes an empty payload, so a Django outage
|
|
294
|
+
* does not fail the build. At runtime it throws: ISR then keeps serving the
|
|
295
|
+
* last good copy. An empty payload there would be cached for the whole
|
|
296
|
+
* revalidate window — a sitemap index with only the static chunk, which
|
|
297
|
+
* crawlers and IndexNow diffs read as every listing removed.
|
|
296
298
|
*/
|
|
297
299
|
|
|
298
300
|
declare function fetchSitemapIndex(apiUrl: string, revalidate: number): Promise<SitemapIndex>;
|
package/dist/sitemap/index.mjs
CHANGED
|
@@ -17,6 +17,14 @@ async function parseAndCheckSize(r, url, label) {
|
|
|
17
17
|
}
|
|
18
18
|
return JSON.parse(text);
|
|
19
19
|
}
|
|
20
|
+
var isBuilding = () => process.env.NEXT_PHASE === "phase-production-build";
|
|
21
|
+
function degrade(fallback, reason, err) {
|
|
22
|
+
if (!isBuilding()) {
|
|
23
|
+
throw new Error(`[sitemap] ${reason}; keeping the last good copy`, { cause: err });
|
|
24
|
+
}
|
|
25
|
+
console.warn(`[sitemap] ${reason}; falling back to empty during build`, err ?? "");
|
|
26
|
+
return fallback;
|
|
27
|
+
}
|
|
20
28
|
var EMPTY_INDEX = {
|
|
21
29
|
sources: [],
|
|
22
30
|
generated_at: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
@@ -36,14 +44,10 @@ async function fetchSitemapIndex(apiUrl, revalidate) {
|
|
|
36
44
|
const r = await fetch(url, {
|
|
37
45
|
next: { revalidate }
|
|
38
46
|
});
|
|
39
|
-
if (!r.ok) {
|
|
40
|
-
console.warn(`[sitemap] index fetch returned ${r.status}; falling back to empty`);
|
|
41
|
-
return EMPTY_INDEX;
|
|
42
|
-
}
|
|
47
|
+
if (!r.ok) return degrade(EMPTY_INDEX, `index fetch returned ${r.status}`);
|
|
43
48
|
return await parseAndCheckSize(r, url, "index");
|
|
44
49
|
} catch (err) {
|
|
45
|
-
|
|
46
|
-
return EMPTY_INDEX;
|
|
50
|
+
return degrade(EMPTY_INDEX, "index fetch failed", err);
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
async function fetchSitemapFeed(apiUrl, source, cursor, revalidate) {
|
|
@@ -54,14 +58,10 @@ async function fetchSitemapFeed(apiUrl, source, cursor, revalidate) {
|
|
|
54
58
|
const r = await fetch(url, {
|
|
55
59
|
next: { revalidate }
|
|
56
60
|
});
|
|
57
|
-
if (!r.ok) {
|
|
58
|
-
console.warn(`[sitemap] feed ${source} returned ${r.status}; falling back to empty`);
|
|
59
|
-
return emptyFeed(source, cursor);
|
|
60
|
-
}
|
|
61
|
+
if (!r.ok) return degrade(emptyFeed(source, cursor), `feed ${source} returned ${r.status}`);
|
|
61
62
|
return await parseAndCheckSize(r, url, `feed ${source}`);
|
|
62
63
|
} catch (err) {
|
|
63
|
-
|
|
64
|
-
return emptyFeed(source, cursor);
|
|
64
|
+
return degrade(emptyFeed(source, cursor), `feed ${source} fetch failed`, err);
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/sitemap/constants.ts","../../src/sitemap/fetch.ts","../../src/sitemap/ids.ts","../../src/sitemap/select.ts","../../src/sitemap/sitemap.ts","../../src/sitemap/index-route.ts","../../src/sitemap/robots.ts"],"sourcesContent":["/**\n * Shared sitemap constants.\n *\n * Pulled out so both the chunk factory (`createDjangoSitemap`) and the\n * index Route Handler (`createSitemapIndex`) agree on the id of the\n * frontend-only static-routes chunk.\n */\n\nexport const STATIC_ID = 'static';\nexport const DEFAULT_INDEX_REVALIDATE = 600;\nexport const DEFAULT_FEED_REVALIDATE = 3600;\n","/**\n * Sitemap fetch helpers — never throw.\n *\n * Failures are converted to empty payloads so a transient Django outage\n * doesn't break `next build`. The next ISR cycle (`revalidate`) recovers\n * automatically.\n */\n\nimport type { SitemapFeedPage, SitemapIndex } from './types';\n\n/**\n * Next.js refuses to write a fetch response larger than 2MB into its data\n * cache. It logs one line and serves the response uncached, so `revalidate`\n * silently becomes a no-op and every regeneration refetches the full payload\n * from Django. Nothing else surfaces this, so we measure the body ourselves.\n */\nconst NEXT_DATA_CACHE_LIMIT = 2 * 1024 * 1024;\n\n// One warning per URL: `sitemap()` runs once per chunk per build, and a\n// repeated line for the same feed reads as a new problem rather than the\n// same one.\nconst oversizeWarned = new Set<string>();\n\n/**\n * Read the body as text so its exact size is known, then parse.\n *\n * `Content-Length` is not a substitute: it is absent under chunked transfer\n * and reports the compressed size when the response is gzipped, while the\n * 2MB ceiling applies to the decoded bytes Next.js would store.\n */\nasync function parseAndCheckSize<T>(r: Response, url: string, label: string): Promise<T> {\n const text = await r.text();\n const bytes = new TextEncoder().encode(text).length;\n if (bytes > NEXT_DATA_CACHE_LIMIT && !oversizeWarned.has(url)) {\n oversizeWarned.add(url);\n console.warn(\n `[sitemap] ${label} is ${bytes} bytes, over Next.js's 2MB data-cache ceiling: ` +\n `revalidate is inert for this feed and every regeneration refetches it from Django. ` +\n `Lower the backend page_size if the extra load matters — note 50,000 URLs is the ` +\n `sitemap protocol's own maximum, so a large payload here is not itself a bug.`,\n );\n }\n return JSON.parse(text) as T;\n}\n\nconst EMPTY_INDEX: SitemapIndex = {\n sources: [],\n generated_at: new Date(0).toISOString(),\n ttl_seconds: 60,\n};\n\nconst emptyFeed = (source: string, cursor: string | null): SitemapFeedPage => ({\n source,\n chunk_id: `${source}-empty`,\n count: 0,\n has_more: false,\n next_cursor: cursor,\n entries: [],\n});\n\nexport async function fetchSitemapIndex(\n apiUrl: string,\n revalidate: number,\n): Promise<SitemapIndex> {\n const url = `${apiUrl}/cfg/sitemap/index/`;\n try {\n const r = await fetch(url, {\n next: { revalidate },\n });\n if (!r.ok) {\n console.warn(`[sitemap] index fetch returned ${r.status}; falling back to empty`);\n return EMPTY_INDEX;\n }\n return await parseAndCheckSize<SitemapIndex>(r, url, 'index');\n } catch (err) {\n console.warn('[sitemap] index fetch failed:', err);\n return EMPTY_INDEX;\n }\n}\n\nexport async function fetchSitemapFeed(\n apiUrl: string,\n source: string,\n cursor: string | null,\n revalidate: number,\n): Promise<SitemapFeedPage> {\n const params = new URLSearchParams({ source });\n if (cursor) params.set('cursor', cursor);\n const url = `${apiUrl}/cfg/sitemap/feed/?${params}`;\n try {\n const r = await fetch(url, {\n next: { revalidate },\n });\n if (!r.ok) {\n console.warn(`[sitemap] feed ${source} returned ${r.status}; falling back to empty`);\n return emptyFeed(source, cursor);\n }\n return await parseAndCheckSize<SitemapFeedPage>(r, url, `feed ${source}`);\n } catch (err) {\n console.warn(`[sitemap] feed ${source} fetch failed:`, err);\n return emptyFeed(source, cursor);\n }\n}\n","/**\n * Sitemap chunk id encoding.\n *\n * Each Next.js sitemap file is addressed by an opaque string id. We\n * round-trip a `(source, cursor)` pair through that id so the default\n * sitemap handler knows which Django chunk to fetch.\n *\n * Separator `--` is unambiguous: backend cursors use URL-safe base64 (`-_`).\n */\n\nexport function encodeChunkId(source: string, cursor: string | null): string {\n return `${source}--${cursor ?? ''}`;\n}\n\nexport function decodeChunkId(id: string): { source: string; cursor: string | null } {\n const sep = id.indexOf('--');\n if (sep === -1) return { source: id, cursor: null };\n const source = id.slice(0, sep);\n const cursorRaw = id.slice(sep + 2);\n return { source, cursor: cursorRaw === '' ? null : cursorRaw };\n}\n","/**\n * Which backend sources a frontend claims.\n *\n * `GET /cfg/sitemap/index/` answers for the whole Django project — every\n * source any installed app registered. One frontend is not always entitled\n * to all of them: a backend serving several brands registers a source per\n * brand (`properties-caribbean`, `properties-bali`), and each brand's app\n * must emit only its own, or it advertises the other's URLs under its own\n * host — every one of them a 404, and the sitemap that carries them is\n * discredited whole.\n *\n * The selector lives here, shared, for the same reason `STATIC_ID` does:\n * `createDjangoSitemap` enumerates the chunks and `createSitemapIndex`\n * enumerates them again for the index document. If the two applied\n * different filters, the index would advertise shards the chunk route\n * refuses to build — and that mismatch is invisible until a crawler reads\n * both. One function, called by both, cannot drift.\n *\n * Source NAMES are the contract. The module ships no notion of a brand or\n * a tenant, and it should not: the backend chose what its source names\n * mean, and only the app knows which of them are its own.\n */\n\nimport type { SitemapIndex, SitemapSourceInfo, SourceSelector } from './types';\n\n/**\n * A predicate from whatever the caller passed.\n *\n * Omitted means every source — the single-tenant default, and what every\n * caller before this option existed already relied on.\n */\nexport function sourceFilter(select: SourceSelector | undefined): (name: string) => boolean {\n if (select === undefined) return () => true;\n if (typeof select === 'function') return select;\n const allowed = new Set(select);\n return (name) => allowed.has(name);\n}\n\n/** The index's sources, narrowed to those the caller claims. */\nexport function selectSources(\n index: SitemapIndex,\n select: SourceSelector | undefined,\n): SitemapSourceInfo[] {\n const keep = sourceFilter(select);\n return index.sources.filter((source) => keep(source.name));\n}\n\n/**\n * Every chunk id the selected sources produce, in index order.\n *\n * The one place `(source, chunk)` becomes an id, so the chunk route and the\n * index document enumerate the same set by construction.\n */\nexport function selectedChunkIds(\n index: SitemapIndex,\n select: SourceSelector | undefined,\n encode: (source: string, cursor: string | null) => string,\n): string[] {\n const ids: string[] = [];\n for (const source of selectSources(index, select)) {\n for (const chunk of source.chunks) {\n ids.push(encode(source.name, chunk.cursor_to));\n }\n }\n return ids;\n}\n\n/**\n * A selector for one tenant of a multi-brand backend.\n *\n * Matches the `<source>-<tenant>` names that a backend registering per\n * tenant emits. It splits on the LAST hyphen and compares the tail exactly,\n * rather than testing a suffix: `endsWith('-bali')` is correct for\n * `properties-bali` but the same shape reads `pricing-plans-premium` as\n * belonging to a `premium` tenant, and a substring match is not a contract.\n *\n * ```ts\n * sources: tenantSources('bali') // properties-bali, communities-bali, …\n * ```\n *\n * Sources with no hyphen never match: an untenanted source belongs to the\n * project rather than to a brand, and claiming it for one is the error this\n * function exists to prevent.\n */\nexport function tenantSources(tenant: string): (name: string) => boolean {\n return (name) => {\n const sep = name.lastIndexOf('-');\n return sep !== -1 && name.slice(sep + 1) === tenant;\n };\n}\n","/**\n * createDjangoSitemap — factory for Next.js `app/sitemap.ts`.\n *\n * Backed by `django_cfg.modules.django_sitemap`: the backend exposes a\n * paginated JSON feed, this factory turns each chunk into a Next.js\n * sitemap file via `generateSitemaps()`.\n *\n * Usage:\n *\n * ```ts\n * // apps/<app>/app/sitemap.ts\n * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';\n *\n * const { generateSitemaps, sitemap } = createDjangoSitemap({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * staticRoutes: [\n * { path: '/', changeFrequency: 'daily', priority: 1.0 },\n * { path: '/catalog', changeFrequency: 'daily', priority: 0.9 },\n * ],\n * });\n *\n * export { generateSitemaps, sitemap as default };\n * export const revalidate = 3600;\n * ```\n *\n * Without `apiUrl`, the sitemap emits only the static routes — handy for\n * marketing sites that don't have a backend-driven URL space.\n */\n\nimport type { MetadataRoute } from 'next';\n\nimport {\n DEFAULT_FEED_REVALIDATE,\n DEFAULT_INDEX_REVALIDATE,\n STATIC_ID,\n} from './constants';\nimport { fetchSitemapFeed, fetchSitemapIndex } from './fetch';\nimport { decodeChunkId, encodeChunkId } from './ids';\nimport { selectedChunkIds, sourceFilter } from './select';\n\nimport type { CreateDjangoSitemapOptions, StaticRoute } from './types';\n\ninterface SitemapApi {\n generateSitemaps: () => Promise<Array<{ id: string }>>;\n sitemap: (props: { id: Promise<string> }) => Promise<MetadataRoute.Sitemap>;\n}\n\nexport function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapApi {\n const {\n host,\n apiUrl,\n staticRoutes = [],\n sources,\n indexRevalidate = DEFAULT_INDEX_REVALIDATE,\n feedRevalidate = DEFAULT_FEED_REVALIDATE,\n } = opts;\n\n return {\n async generateSitemaps() {\n const ids: Array<{ id: string }> = [{ id: STATIC_ID }];\n if (!apiUrl) return ids;\n const index = await fetchSitemapIndex(apiUrl, indexRevalidate);\n for (const id of selectedChunkIds(index, sources, encodeChunkId)) {\n ids.push({ id });\n }\n return ids;\n },\n\n async sitemap({ id: idPromise }) {\n const id = await idPromise;\n\n if (id === STATIC_ID) {\n return renderStatic(host, staticRoutes);\n }\n\n if (!apiUrl) return [];\n\n const { source, cursor } = decodeChunkId(id);\n // A chunk this app does not claim renders empty rather than fetching.\n // `generateSitemaps` never mints such an id, but the route stays\n // reachable by hand, and serving another tenant's URLs from a guessed\n // address would undo the filter above.\n if (!sourceFilter(sources)(source)) return [];\n\n const feed = await fetchSitemapFeed(apiUrl, source, cursor, feedRevalidate);\n return feed.entries.map((e) => ({\n url: `${host}${e.loc}`,\n lastModified: e.lastmod ? new Date(e.lastmod) : undefined,\n }));\n },\n };\n}\n\nfunction renderStatic(host: string, routes: StaticRoute[]): MetadataRoute.Sitemap {\n return routes.map((r) => ({\n url: `${host}${r.path}`,\n changeFrequency: r.changeFrequency,\n priority: r.priority,\n }));\n}\n","/**\n * createSitemapIndex — Route Handler factory for the sitemap-index XML.\n *\n * Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`\n * but does NOT emit a single sitemap-index document. Without one,\n * robots.txt + crawlers point at a 404 and never discover the chunks.\n * This factory closes that gap: it returns a `GET` Route Handler that\n * mints a `<sitemapindex>` XML listing every chunk the matching\n * `createDjangoSitemap` would produce.\n *\n * Mount it at **`app/sitemap_index.xml/route.ts`** — NOT `sitemap.xml`.\n * `app/sitemap.ts` (where `createDjangoSitemap` lives) already owns\n * `/sitemap.xml` in Next.js's metadata routing, even when its output is\n * fan-out via `generateSitemaps()` and the singular URL serves 404.\n * Putting a route handler at `app/sitemap.xml/route.ts` throws at build\n * time: *Conflicting route and metadata at /sitemap.xml*.\n *\n * Usage — the full pattern with the matching chunk + robots factories:\n *\n * ```ts\n * // apps/<app>/app/sitemap.ts — chunks\n * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';\n * const { generateSitemaps, sitemap } = createDjangoSitemap({ ... });\n * export { generateSitemaps, sitemap as default };\n *\n * // apps/<app>/app/sitemap_index.xml/route.ts — the index\n * import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';\n * export const { GET } = createSitemapIndex({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * });\n * export const revalidate = 600;\n *\n * // apps/<app>/app/robots.ts — point crawlers at the index URL above\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n * const host = process.env.NEXT_PUBLIC_SITE_URL!;\n * export default createRobots({\n * host,\n * sitemap: `${host}/sitemap_index.xml`,\n * });\n * ```\n *\n * Keep `host` / `apiUrl` in sync with the same app's `app/sitemap.ts`\n * — the index just enumerates the chunks the other file emits.\n */\n\nimport {\n DEFAULT_INDEX_REVALIDATE,\n STATIC_ID,\n} from './constants';\nimport { fetchSitemapIndex } from './fetch';\nimport { encodeChunkId } from './ids';\nimport { selectedChunkIds } from './select';\n\nimport type { CreateSitemapIndexOptions } from './types';\n\ninterface SitemapIndexRoute {\n GET: () => Promise<Response>;\n}\n\nexport function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapIndexRoute {\n const {\n host,\n apiUrl,\n sources,\n indexRevalidate = DEFAULT_INDEX_REVALIDATE,\n includeStatic = true,\n } = opts;\n\n return {\n async GET() {\n const ids: string[] = includeStatic ? [STATIC_ID] : [];\n\n if (apiUrl) {\n const index = await fetchSitemapIndex(apiUrl, indexRevalidate);\n ids.push(...selectedChunkIds(index, sources, encodeChunkId));\n }\n\n const lastmod = new Date().toISOString();\n const body = renderIndex(host, ids, lastmod);\n\n return new Response(body, {\n status: 200,\n headers: {\n 'content-type': 'application/xml; charset=utf-8',\n // Mirror the per-chunk revalidate so a cached CDN edge expires\n // at the same cadence as the index it points at.\n 'cache-control': `public, s-maxage=${indexRevalidate}, stale-while-revalidate=${indexRevalidate}`,\n },\n });\n },\n };\n}\n\nfunction renderIndex(host: string, ids: string[], lastmod: string): string {\n const lines: string[] = [];\n lines.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>');\n lines.push('<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">');\n for (const id of ids) {\n lines.push(' <sitemap>');\n lines.push(` <loc>${escapeXml(`${host}/sitemap/${id}.xml`)}</loc>`);\n lines.push(` <lastmod>${lastmod}</lastmod>`);\n lines.push(' </sitemap>');\n }\n lines.push('</sitemapindex>');\n return lines.join('\\n');\n}\n\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n","/**\n * createRobots — factory for Next.js `app/robots.ts`.\n *\n * Emits a single `User-Agent: *` block plus a Sitemap pointer. Disallow\n * defaults cover the common cases (auth, account, API surface) — override\n * per app if needed.\n *\n * The `sitemap` default `${host}/sitemap.xml` only makes sense for the\n * single-file case. When the app uses `createDjangoSitemap`\n * (`generateSitemaps()` → chunked output) **plus** `createSitemapIndex`\n * — the canonical pairing — pass an explicit\n * `sitemap: \\`${host}/sitemap_index.xml\\`` to point crawlers at the\n * index. The default `/sitemap.xml` will serve a 404 in that setup\n * (Next.js reserves it for the metadata route but doesn't emit content\n * there).\n *\n * Usage:\n *\n * ```ts\n * // apps/<app>/app/robots.ts\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n *\n * const host = process.env.NEXT_PUBLIC_SITE_URL!;\n *\n * export default createRobots({\n * host,\n * disallow: ['/account/', '/auth', '/api/', '/apix/'],\n * sitemap: `${host}/sitemap_index.xml`, // matches createSitemapIndex\n * });\n * ```\n */\n\nimport type { MetadataRoute } from 'next';\n\nimport type { CreateRobotsOptions } from './types';\n\nconst DEFAULT_DISALLOW = ['/account/', '/auth', '/api/'];\n\nexport function createRobots(opts: CreateRobotsOptions): () => MetadataRoute.Robots {\n const { host, disallow = DEFAULT_DISALLOW, sitemap } = opts;\n return () => ({\n rules: [{ userAgent: '*', allow: '/', disallow }],\n sitemap: sitemap ?? `${host}/sitemap.xml`,\n host,\n });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/sitemap/constants.ts","../../src/sitemap/fetch.ts","../../src/sitemap/ids.ts","../../src/sitemap/select.ts","../../src/sitemap/sitemap.ts","../../src/sitemap/index-route.ts","../../src/sitemap/robots.ts"],"sourcesContent":["/**\n * Shared sitemap constants.\n *\n * Pulled out so both the chunk factory (`createDjangoSitemap`) and the\n * index Route Handler (`createSitemapIndex`) agree on the id of the\n * frontend-only static-routes chunk.\n */\n\nexport const STATIC_ID = 'static';\nexport const DEFAULT_INDEX_REVALIDATE = 600;\nexport const DEFAULT_FEED_REVALIDATE = 3600;\n","/**\n * Sitemap fetch helpers.\n *\n * During `next build` a failure becomes an empty payload, so a Django outage\n * does not fail the build. At runtime it throws: ISR then keeps serving the\n * last good copy. An empty payload there would be cached for the whole\n * revalidate window — a sitemap index with only the static chunk, which\n * crawlers and IndexNow diffs read as every listing removed.\n */\n\nimport type { SitemapFeedPage, SitemapIndex } from './types';\n\n/**\n * Next.js refuses to write a fetch response larger than 2MB into its data\n * cache. It logs one line and serves the response uncached, so `revalidate`\n * silently becomes a no-op and every regeneration refetches the full payload\n * from Django. Nothing else surfaces this, so we measure the body ourselves.\n */\nconst NEXT_DATA_CACHE_LIMIT = 2 * 1024 * 1024;\n\n// One warning per URL: `sitemap()` runs once per chunk per build, and a\n// repeated line for the same feed reads as a new problem rather than the\n// same one.\nconst oversizeWarned = new Set<string>();\n\n/**\n * Read the body as text so its exact size is known, then parse.\n *\n * `Content-Length` is not a substitute: it is absent under chunked transfer\n * and reports the compressed size when the response is gzipped, while the\n * 2MB ceiling applies to the decoded bytes Next.js would store.\n */\nasync function parseAndCheckSize<T>(r: Response, url: string, label: string): Promise<T> {\n const text = await r.text();\n const bytes = new TextEncoder().encode(text).length;\n if (bytes > NEXT_DATA_CACHE_LIMIT && !oversizeWarned.has(url)) {\n oversizeWarned.add(url);\n console.warn(\n `[sitemap] ${label} is ${bytes} bytes, over Next.js's 2MB data-cache ceiling: ` +\n `revalidate is inert for this feed and every regeneration refetches it from Django. ` +\n `Lower the backend page_size if the extra load matters — note 50,000 URLs is the ` +\n `sitemap protocol's own maximum, so a large payload here is not itself a bug.`,\n );\n }\n return JSON.parse(text) as T;\n}\n\n/** True while `next build` prerenders; false in the serving process. */\nconst isBuilding = (): boolean => process.env.NEXT_PHASE === 'phase-production-build';\n\nfunction degrade<T>(fallback: T, reason: string, err?: unknown): T {\n if (!isBuilding()) {\n throw new Error(`[sitemap] ${reason}; keeping the last good copy`, { cause: err });\n }\n console.warn(`[sitemap] ${reason}; falling back to empty during build`, err ?? '');\n return fallback;\n}\n\nconst EMPTY_INDEX: SitemapIndex = {\n sources: [],\n generated_at: new Date(0).toISOString(),\n ttl_seconds: 60,\n};\n\nconst emptyFeed = (source: string, cursor: string | null): SitemapFeedPage => ({\n source,\n chunk_id: `${source}-empty`,\n count: 0,\n has_more: false,\n next_cursor: cursor,\n entries: [],\n});\n\nexport async function fetchSitemapIndex(\n apiUrl: string,\n revalidate: number,\n): Promise<SitemapIndex> {\n const url = `${apiUrl}/cfg/sitemap/index/`;\n try {\n const r = await fetch(url, {\n next: { revalidate },\n });\n if (!r.ok) return degrade(EMPTY_INDEX, `index fetch returned ${r.status}`);\n return await parseAndCheckSize<SitemapIndex>(r, url, 'index');\n } catch (err) {\n return degrade(EMPTY_INDEX, 'index fetch failed', err);\n }\n}\n\nexport async function fetchSitemapFeed(\n apiUrl: string,\n source: string,\n cursor: string | null,\n revalidate: number,\n): Promise<SitemapFeedPage> {\n const params = new URLSearchParams({ source });\n if (cursor) params.set('cursor', cursor);\n const url = `${apiUrl}/cfg/sitemap/feed/?${params}`;\n try {\n const r = await fetch(url, {\n next: { revalidate },\n });\n if (!r.ok) return degrade(emptyFeed(source, cursor), `feed ${source} returned ${r.status}`);\n return await parseAndCheckSize<SitemapFeedPage>(r, url, `feed ${source}`);\n } catch (err) {\n return degrade(emptyFeed(source, cursor), `feed ${source} fetch failed`, err);\n }\n}\n","/**\n * Sitemap chunk id encoding.\n *\n * Each Next.js sitemap file is addressed by an opaque string id. We\n * round-trip a `(source, cursor)` pair through that id so the default\n * sitemap handler knows which Django chunk to fetch.\n *\n * Separator `--` is unambiguous: backend cursors use URL-safe base64 (`-_`).\n */\n\nexport function encodeChunkId(source: string, cursor: string | null): string {\n return `${source}--${cursor ?? ''}`;\n}\n\nexport function decodeChunkId(id: string): { source: string; cursor: string | null } {\n const sep = id.indexOf('--');\n if (sep === -1) return { source: id, cursor: null };\n const source = id.slice(0, sep);\n const cursorRaw = id.slice(sep + 2);\n return { source, cursor: cursorRaw === '' ? null : cursorRaw };\n}\n","/**\n * Which backend sources a frontend claims.\n *\n * `GET /cfg/sitemap/index/` answers for the whole Django project — every\n * source any installed app registered. One frontend is not always entitled\n * to all of them: a backend serving several brands registers a source per\n * brand (`properties-caribbean`, `properties-bali`), and each brand's app\n * must emit only its own, or it advertises the other's URLs under its own\n * host — every one of them a 404, and the sitemap that carries them is\n * discredited whole.\n *\n * The selector lives here, shared, for the same reason `STATIC_ID` does:\n * `createDjangoSitemap` enumerates the chunks and `createSitemapIndex`\n * enumerates them again for the index document. If the two applied\n * different filters, the index would advertise shards the chunk route\n * refuses to build — and that mismatch is invisible until a crawler reads\n * both. One function, called by both, cannot drift.\n *\n * Source NAMES are the contract. The module ships no notion of a brand or\n * a tenant, and it should not: the backend chose what its source names\n * mean, and only the app knows which of them are its own.\n */\n\nimport type { SitemapIndex, SitemapSourceInfo, SourceSelector } from './types';\n\n/**\n * A predicate from whatever the caller passed.\n *\n * Omitted means every source — the single-tenant default, and what every\n * caller before this option existed already relied on.\n */\nexport function sourceFilter(select: SourceSelector | undefined): (name: string) => boolean {\n if (select === undefined) return () => true;\n if (typeof select === 'function') return select;\n const allowed = new Set(select);\n return (name) => allowed.has(name);\n}\n\n/** The index's sources, narrowed to those the caller claims. */\nexport function selectSources(\n index: SitemapIndex,\n select: SourceSelector | undefined,\n): SitemapSourceInfo[] {\n const keep = sourceFilter(select);\n return index.sources.filter((source) => keep(source.name));\n}\n\n/**\n * Every chunk id the selected sources produce, in index order.\n *\n * The one place `(source, chunk)` becomes an id, so the chunk route and the\n * index document enumerate the same set by construction.\n */\nexport function selectedChunkIds(\n index: SitemapIndex,\n select: SourceSelector | undefined,\n encode: (source: string, cursor: string | null) => string,\n): string[] {\n const ids: string[] = [];\n for (const source of selectSources(index, select)) {\n for (const chunk of source.chunks) {\n ids.push(encode(source.name, chunk.cursor_to));\n }\n }\n return ids;\n}\n\n/**\n * A selector for one tenant of a multi-brand backend.\n *\n * Matches the `<source>-<tenant>` names that a backend registering per\n * tenant emits. It splits on the LAST hyphen and compares the tail exactly,\n * rather than testing a suffix: `endsWith('-bali')` is correct for\n * `properties-bali` but the same shape reads `pricing-plans-premium` as\n * belonging to a `premium` tenant, and a substring match is not a contract.\n *\n * ```ts\n * sources: tenantSources('bali') // properties-bali, communities-bali, …\n * ```\n *\n * Sources with no hyphen never match: an untenanted source belongs to the\n * project rather than to a brand, and claiming it for one is the error this\n * function exists to prevent.\n */\nexport function tenantSources(tenant: string): (name: string) => boolean {\n return (name) => {\n const sep = name.lastIndexOf('-');\n return sep !== -1 && name.slice(sep + 1) === tenant;\n };\n}\n","/**\n * createDjangoSitemap — factory for Next.js `app/sitemap.ts`.\n *\n * Backed by `django_cfg.modules.django_sitemap`: the backend exposes a\n * paginated JSON feed, this factory turns each chunk into a Next.js\n * sitemap file via `generateSitemaps()`.\n *\n * Usage:\n *\n * ```ts\n * // apps/<app>/app/sitemap.ts\n * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';\n *\n * const { generateSitemaps, sitemap } = createDjangoSitemap({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * staticRoutes: [\n * { path: '/', changeFrequency: 'daily', priority: 1.0 },\n * { path: '/catalog', changeFrequency: 'daily', priority: 0.9 },\n * ],\n * });\n *\n * export { generateSitemaps, sitemap as default };\n * export const revalidate = 3600;\n * ```\n *\n * Without `apiUrl`, the sitemap emits only the static routes — handy for\n * marketing sites that don't have a backend-driven URL space.\n */\n\nimport type { MetadataRoute } from 'next';\n\nimport {\n DEFAULT_FEED_REVALIDATE,\n DEFAULT_INDEX_REVALIDATE,\n STATIC_ID,\n} from './constants';\nimport { fetchSitemapFeed, fetchSitemapIndex } from './fetch';\nimport { decodeChunkId, encodeChunkId } from './ids';\nimport { selectedChunkIds, sourceFilter } from './select';\n\nimport type { CreateDjangoSitemapOptions, StaticRoute } from './types';\n\ninterface SitemapApi {\n generateSitemaps: () => Promise<Array<{ id: string }>>;\n sitemap: (props: { id: Promise<string> }) => Promise<MetadataRoute.Sitemap>;\n}\n\nexport function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapApi {\n const {\n host,\n apiUrl,\n staticRoutes = [],\n sources,\n indexRevalidate = DEFAULT_INDEX_REVALIDATE,\n feedRevalidate = DEFAULT_FEED_REVALIDATE,\n } = opts;\n\n return {\n async generateSitemaps() {\n const ids: Array<{ id: string }> = [{ id: STATIC_ID }];\n if (!apiUrl) return ids;\n const index = await fetchSitemapIndex(apiUrl, indexRevalidate);\n for (const id of selectedChunkIds(index, sources, encodeChunkId)) {\n ids.push({ id });\n }\n return ids;\n },\n\n async sitemap({ id: idPromise }) {\n const id = await idPromise;\n\n if (id === STATIC_ID) {\n return renderStatic(host, staticRoutes);\n }\n\n if (!apiUrl) return [];\n\n const { source, cursor } = decodeChunkId(id);\n // A chunk this app does not claim renders empty rather than fetching.\n // `generateSitemaps` never mints such an id, but the route stays\n // reachable by hand, and serving another tenant's URLs from a guessed\n // address would undo the filter above.\n if (!sourceFilter(sources)(source)) return [];\n\n const feed = await fetchSitemapFeed(apiUrl, source, cursor, feedRevalidate);\n return feed.entries.map((e) => ({\n url: `${host}${e.loc}`,\n lastModified: e.lastmod ? new Date(e.lastmod) : undefined,\n }));\n },\n };\n}\n\nfunction renderStatic(host: string, routes: StaticRoute[]): MetadataRoute.Sitemap {\n return routes.map((r) => ({\n url: `${host}${r.path}`,\n changeFrequency: r.changeFrequency,\n priority: r.priority,\n }));\n}\n","/**\n * createSitemapIndex — Route Handler factory for the sitemap-index XML.\n *\n * Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`\n * but does NOT emit a single sitemap-index document. Without one,\n * robots.txt + crawlers point at a 404 and never discover the chunks.\n * This factory closes that gap: it returns a `GET` Route Handler that\n * mints a `<sitemapindex>` XML listing every chunk the matching\n * `createDjangoSitemap` would produce.\n *\n * Mount it at **`app/sitemap_index.xml/route.ts`** — NOT `sitemap.xml`.\n * `app/sitemap.ts` (where `createDjangoSitemap` lives) already owns\n * `/sitemap.xml` in Next.js's metadata routing, even when its output is\n * fan-out via `generateSitemaps()` and the singular URL serves 404.\n * Putting a route handler at `app/sitemap.xml/route.ts` throws at build\n * time: *Conflicting route and metadata at /sitemap.xml*.\n *\n * Usage — the full pattern with the matching chunk + robots factories:\n *\n * ```ts\n * // apps/<app>/app/sitemap.ts — chunks\n * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';\n * const { generateSitemaps, sitemap } = createDjangoSitemap({ ... });\n * export { generateSitemaps, sitemap as default };\n *\n * // apps/<app>/app/sitemap_index.xml/route.ts — the index\n * import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';\n * export const { GET } = createSitemapIndex({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * });\n * export const revalidate = 600;\n *\n * // apps/<app>/app/robots.ts — point crawlers at the index URL above\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n * const host = process.env.NEXT_PUBLIC_SITE_URL!;\n * export default createRobots({\n * host,\n * sitemap: `${host}/sitemap_index.xml`,\n * });\n * ```\n *\n * Keep `host` / `apiUrl` in sync with the same app's `app/sitemap.ts`\n * — the index just enumerates the chunks the other file emits.\n */\n\nimport {\n DEFAULT_INDEX_REVALIDATE,\n STATIC_ID,\n} from './constants';\nimport { fetchSitemapIndex } from './fetch';\nimport { encodeChunkId } from './ids';\nimport { selectedChunkIds } from './select';\n\nimport type { CreateSitemapIndexOptions } from './types';\n\ninterface SitemapIndexRoute {\n GET: () => Promise<Response>;\n}\n\nexport function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapIndexRoute {\n const {\n host,\n apiUrl,\n sources,\n indexRevalidate = DEFAULT_INDEX_REVALIDATE,\n includeStatic = true,\n } = opts;\n\n return {\n async GET() {\n const ids: string[] = includeStatic ? [STATIC_ID] : [];\n\n if (apiUrl) {\n const index = await fetchSitemapIndex(apiUrl, indexRevalidate);\n ids.push(...selectedChunkIds(index, sources, encodeChunkId));\n }\n\n const lastmod = new Date().toISOString();\n const body = renderIndex(host, ids, lastmod);\n\n return new Response(body, {\n status: 200,\n headers: {\n 'content-type': 'application/xml; charset=utf-8',\n // Mirror the per-chunk revalidate so a cached CDN edge expires\n // at the same cadence as the index it points at.\n 'cache-control': `public, s-maxage=${indexRevalidate}, stale-while-revalidate=${indexRevalidate}`,\n },\n });\n },\n };\n}\n\nfunction renderIndex(host: string, ids: string[], lastmod: string): string {\n const lines: string[] = [];\n lines.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>');\n lines.push('<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">');\n for (const id of ids) {\n lines.push(' <sitemap>');\n lines.push(` <loc>${escapeXml(`${host}/sitemap/${id}.xml`)}</loc>`);\n lines.push(` <lastmod>${lastmod}</lastmod>`);\n lines.push(' </sitemap>');\n }\n lines.push('</sitemapindex>');\n return lines.join('\\n');\n}\n\nfunction escapeXml(s: string): string {\n return s\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n","/**\n * createRobots — factory for Next.js `app/robots.ts`.\n *\n * Emits a single `User-Agent: *` block plus a Sitemap pointer. Disallow\n * defaults cover the common cases (auth, account, API surface) — override\n * per app if needed.\n *\n * The `sitemap` default `${host}/sitemap.xml` only makes sense for the\n * single-file case. When the app uses `createDjangoSitemap`\n * (`generateSitemaps()` → chunked output) **plus** `createSitemapIndex`\n * — the canonical pairing — pass an explicit\n * `sitemap: \\`${host}/sitemap_index.xml\\`` to point crawlers at the\n * index. The default `/sitemap.xml` will serve a 404 in that setup\n * (Next.js reserves it for the metadata route but doesn't emit content\n * there).\n *\n * Usage:\n *\n * ```ts\n * // apps/<app>/app/robots.ts\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n *\n * const host = process.env.NEXT_PUBLIC_SITE_URL!;\n *\n * export default createRobots({\n * host,\n * disallow: ['/account/', '/auth', '/api/', '/apix/'],\n * sitemap: `${host}/sitemap_index.xml`, // matches createSitemapIndex\n * });\n * ```\n */\n\nimport type { MetadataRoute } from 'next';\n\nimport type { CreateRobotsOptions } from './types';\n\nconst DEFAULT_DISALLOW = ['/account/', '/auth', '/api/'];\n\nexport function createRobots(opts: CreateRobotsOptions): () => MetadataRoute.Robots {\n const { host, disallow = DEFAULT_DISALLOW, sitemap } = opts;\n return () => ({\n rules: [{ userAgent: '*', allow: '/', disallow }],\n sitemap: sitemap ?? `${host}/sitemap.xml`,\n host,\n });\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.571",
|
|
4
4
|
"description": "Next.js server utilities: sitemap, health, OG images, contact forms, navigation, config",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"nextjs",
|
|
@@ -149,9 +149,9 @@
|
|
|
149
149
|
"ai-docs": "tsx src/ai/cli.ts"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
|
-
"@djangocfg/devtools": "^2.1.
|
|
153
|
-
"@djangocfg/i18n": "^2.1.
|
|
154
|
-
"@djangocfg/ui-core": "^2.1.
|
|
152
|
+
"@djangocfg/devtools": "^2.1.571",
|
|
153
|
+
"@djangocfg/i18n": "^2.1.571",
|
|
154
|
+
"@djangocfg/ui-core": "^2.1.571",
|
|
155
155
|
"next": "^16.2.2"
|
|
156
156
|
},
|
|
157
157
|
"peerDependenciesMeta": {
|
|
@@ -170,11 +170,11 @@
|
|
|
170
170
|
"semver": "^7.8.5"
|
|
171
171
|
},
|
|
172
172
|
"devDependencies": {
|
|
173
|
-
"@djangocfg/devtools": "^2.1.
|
|
174
|
-
"@djangocfg/i18n": "^2.1.
|
|
175
|
-
"@djangocfg/layouts": "^2.1.
|
|
176
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
177
|
-
"@djangocfg/ui-core": "^2.1.
|
|
173
|
+
"@djangocfg/devtools": "^2.1.571",
|
|
174
|
+
"@djangocfg/i18n": "^2.1.571",
|
|
175
|
+
"@djangocfg/layouts": "^2.1.571",
|
|
176
|
+
"@djangocfg/typescript-config": "^2.1.571",
|
|
177
|
+
"@djangocfg/ui-core": "^2.1.571",
|
|
178
178
|
"@types/node": "^25.9.5",
|
|
179
179
|
"@types/react": "19.2.15",
|
|
180
180
|
"@types/react-dom": "19.2.3",
|
package/src/sitemap/README.md
CHANGED
|
@@ -195,15 +195,25 @@ on the Django side; every Redis key includes it.
|
|
|
195
195
|
|
|
196
196
|
## Failure mode
|
|
197
197
|
|
|
198
|
-
`fetchSitemapIndex` and `fetchSitemapFeed`
|
|
199
|
-
|
|
198
|
+
`fetchSitemapIndex` and `fetchSitemapFeed` behave differently in the two
|
|
199
|
+
phases.
|
|
200
|
+
|
|
201
|
+
**During `next build`** (`NEXT_PHASE=phase-production-build`) they never
|
|
202
|
+
throw. A Django outage is downgraded:
|
|
200
203
|
|
|
201
204
|
- empty index → only the static chunk is emitted
|
|
202
205
|
- empty feed → that chunk renders an empty `<urlset/>`
|
|
203
206
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
+
The build completes with no coupling to Django uptime, and the next ISR cycle
|
|
208
|
+
heals it.
|
|
209
|
+
|
|
210
|
+
**At runtime they throw.** On a failed regeneration Next keeps serving the
|
|
211
|
+
last good copy. The old runtime fallback cached the degraded answer for the
|
|
212
|
+
whole revalidate window: a Traefik 404 during a Django recreate became a
|
|
213
|
+
static-only sitemap index for ten minutes. A consumer that diffs the sitemap,
|
|
214
|
+
such as an IndexNow sweep, then read every listing as removed and resubmitted
|
|
215
|
+
the entire catalogue. A cold start with Django down answers 500 until Django
|
|
216
|
+
is back, which a crawler retries.
|
|
207
217
|
|
|
208
218
|
## Deployment
|
|
209
219
|
|
package/src/sitemap/fetch.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sitemap fetch helpers
|
|
2
|
+
* Sitemap fetch helpers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* During `next build` a failure becomes an empty payload, so a Django outage
|
|
5
|
+
* does not fail the build. At runtime it throws: ISR then keeps serving the
|
|
6
|
+
* last good copy. An empty payload there would be cached for the whole
|
|
7
|
+
* revalidate window — a sitemap index with only the static chunk, which
|
|
8
|
+
* crawlers and IndexNow diffs read as every listing removed.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { SitemapFeedPage, SitemapIndex } from './types';
|
|
@@ -43,6 +45,17 @@ async function parseAndCheckSize<T>(r: Response, url: string, label: string): Pr
|
|
|
43
45
|
return JSON.parse(text) as T;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/** True while `next build` prerenders; false in the serving process. */
|
|
49
|
+
const isBuilding = (): boolean => process.env.NEXT_PHASE === 'phase-production-build';
|
|
50
|
+
|
|
51
|
+
function degrade<T>(fallback: T, reason: string, err?: unknown): T {
|
|
52
|
+
if (!isBuilding()) {
|
|
53
|
+
throw new Error(`[sitemap] ${reason}; keeping the last good copy`, { cause: err });
|
|
54
|
+
}
|
|
55
|
+
console.warn(`[sitemap] ${reason}; falling back to empty during build`, err ?? '');
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
const EMPTY_INDEX: SitemapIndex = {
|
|
47
60
|
sources: [],
|
|
48
61
|
generated_at: new Date(0).toISOString(),
|
|
@@ -67,14 +80,10 @@ export async function fetchSitemapIndex(
|
|
|
67
80
|
const r = await fetch(url, {
|
|
68
81
|
next: { revalidate },
|
|
69
82
|
});
|
|
70
|
-
if (!r.ok) {
|
|
71
|
-
console.warn(`[sitemap] index fetch returned ${r.status}; falling back to empty`);
|
|
72
|
-
return EMPTY_INDEX;
|
|
73
|
-
}
|
|
83
|
+
if (!r.ok) return degrade(EMPTY_INDEX, `index fetch returned ${r.status}`);
|
|
74
84
|
return await parseAndCheckSize<SitemapIndex>(r, url, 'index');
|
|
75
85
|
} catch (err) {
|
|
76
|
-
|
|
77
|
-
return EMPTY_INDEX;
|
|
86
|
+
return degrade(EMPTY_INDEX, 'index fetch failed', err);
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
|
|
@@ -91,13 +100,9 @@ export async function fetchSitemapFeed(
|
|
|
91
100
|
const r = await fetch(url, {
|
|
92
101
|
next: { revalidate },
|
|
93
102
|
});
|
|
94
|
-
if (!r.ok) {
|
|
95
|
-
console.warn(`[sitemap] feed ${source} returned ${r.status}; falling back to empty`);
|
|
96
|
-
return emptyFeed(source, cursor);
|
|
97
|
-
}
|
|
103
|
+
if (!r.ok) return degrade(emptyFeed(source, cursor), `feed ${source} returned ${r.status}`);
|
|
98
104
|
return await parseAndCheckSize<SitemapFeedPage>(r, url, `feed ${source}`);
|
|
99
105
|
} catch (err) {
|
|
100
|
-
|
|
101
|
-
return emptyFeed(source, cursor);
|
|
106
|
+
return degrade(emptyFeed(source, cursor), `feed ${source} fetch failed`, err);
|
|
102
107
|
}
|
|
103
108
|
}
|