@djangocfg/nextjs 2.1.543 → 2.1.545
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.d.mts +1 -1
- package/dist/index.mjs +36 -9
- package/dist/index.mjs.map +1 -1
- package/dist/sitemap/index.d.mts +68 -1
- package/dist/sitemap/index.mjs +37 -10
- package/dist/sitemap/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/sitemap/README.md +53 -0
- package/src/sitemap/index-route.ts +3 -5
- package/src/sitemap/index.ts +2 -0
- package/src/sitemap/select.ts +90 -0
- package/src/sitemap/sitemap.ts +10 -4
- package/src/sitemap/types.ts +19 -0
package/dist/sitemap/index.d.mts
CHANGED
|
@@ -36,6 +36,15 @@ interface SitemapFeedPage {
|
|
|
36
36
|
next_cursor: string | null;
|
|
37
37
|
entries: SitemapEntry[];
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Which backend sources a frontend claims: a list of names, or a predicate.
|
|
41
|
+
*
|
|
42
|
+
* Omit for every source in the index — right whenever the backend serves
|
|
43
|
+
* this frontend alone. Pass one when several frontends share a backend, so
|
|
44
|
+
* each emits only the URLs its own host can resolve. See `tenantSources`
|
|
45
|
+
* for the `<source>-<tenant>` convention.
|
|
46
|
+
*/
|
|
47
|
+
type SourceSelector = string[] | ((name: string) => boolean);
|
|
39
48
|
interface StaticRoute {
|
|
40
49
|
path: string;
|
|
41
50
|
changeFrequency?: ChangeFreq;
|
|
@@ -51,6 +60,9 @@ interface CreateDjangoSitemapOptions {
|
|
|
51
60
|
/** Frontend-only routes the backend doesn't know about. Auth/account
|
|
52
61
|
* pages should NOT be listed here. */
|
|
53
62
|
staticRoutes?: StaticRoute[];
|
|
63
|
+
/** Backend sources to emit. Default: every source the index reports.
|
|
64
|
+
* Keep in step with the same app's `createSitemapIndex`. */
|
|
65
|
+
sources?: SourceSelector;
|
|
54
66
|
/** Index revalidate window in seconds. Default: 600. */
|
|
55
67
|
indexRevalidate?: number;
|
|
56
68
|
/** Feed (chunk) revalidate window in seconds. Default: 3600. */
|
|
@@ -66,6 +78,10 @@ interface CreateSitemapIndexOptions {
|
|
|
66
78
|
/** Revalidate window in seconds for both the upstream `index/` fetch
|
|
67
79
|
* and the emitted XML's `s-maxage`. Default: 600. */
|
|
68
80
|
indexRevalidate?: number;
|
|
81
|
+
/** Backend sources to list. Default: every source the index reports.
|
|
82
|
+
* MUST match the same app's `createDjangoSitemap` — a mismatch
|
|
83
|
+
* advertises chunks the sitemap route will not build. */
|
|
84
|
+
sources?: SourceSelector;
|
|
69
85
|
/** Include the `static` chunk id at the top of the index. Turn off if
|
|
70
86
|
* the app's `sitemap.ts` opts out of `staticRoutes`. Default: true. */
|
|
71
87
|
includeStatic?: boolean;
|
|
@@ -220,6 +236,57 @@ declare function decodeChunkId(id: string): {
|
|
|
220
236
|
cursor: string | null;
|
|
221
237
|
};
|
|
222
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Which backend sources a frontend claims.
|
|
241
|
+
*
|
|
242
|
+
* `GET /cfg/sitemap/index/` answers for the whole Django project — every
|
|
243
|
+
* source any installed app registered. One frontend is not always entitled
|
|
244
|
+
* to all of them: a backend serving several brands registers a source per
|
|
245
|
+
* brand (`properties-caribbean`, `properties-bali`), and each brand's app
|
|
246
|
+
* must emit only its own, or it advertises the other's URLs under its own
|
|
247
|
+
* host — every one of them a 404, and the sitemap that carries them is
|
|
248
|
+
* discredited whole.
|
|
249
|
+
*
|
|
250
|
+
* The selector lives here, shared, for the same reason `STATIC_ID` does:
|
|
251
|
+
* `createDjangoSitemap` enumerates the chunks and `createSitemapIndex`
|
|
252
|
+
* enumerates them again for the index document. If the two applied
|
|
253
|
+
* different filters, the index would advertise shards the chunk route
|
|
254
|
+
* refuses to build — and that mismatch is invisible until a crawler reads
|
|
255
|
+
* both. One function, called by both, cannot drift.
|
|
256
|
+
*
|
|
257
|
+
* Source NAMES are the contract. The module ships no notion of a brand or
|
|
258
|
+
* a tenant, and it should not: the backend chose what its source names
|
|
259
|
+
* mean, and only the app knows which of them are its own.
|
|
260
|
+
*/
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A predicate from whatever the caller passed.
|
|
264
|
+
*
|
|
265
|
+
* Omitted means every source — the single-tenant default, and what every
|
|
266
|
+
* caller before this option existed already relied on.
|
|
267
|
+
*/
|
|
268
|
+
declare function sourceFilter(select: SourceSelector | undefined): (name: string) => boolean;
|
|
269
|
+
/** The index's sources, narrowed to those the caller claims. */
|
|
270
|
+
declare function selectSources(index: SitemapIndex, select: SourceSelector | undefined): SitemapSourceInfo[];
|
|
271
|
+
/**
|
|
272
|
+
* A selector for one tenant of a multi-brand backend.
|
|
273
|
+
*
|
|
274
|
+
* Matches the `<source>-<tenant>` names that a backend registering per
|
|
275
|
+
* tenant emits. It splits on the LAST hyphen and compares the tail exactly,
|
|
276
|
+
* rather than testing a suffix: `endsWith('-bali')` is correct for
|
|
277
|
+
* `properties-bali` but the same shape reads `pricing-plans-premium` as
|
|
278
|
+
* belonging to a `premium` tenant, and a substring match is not a contract.
|
|
279
|
+
*
|
|
280
|
+
* ```ts
|
|
281
|
+
* sources: tenantSources('bali') // properties-bali, communities-bali, …
|
|
282
|
+
* ```
|
|
283
|
+
*
|
|
284
|
+
* Sources with no hyphen never match: an untenanted source belongs to the
|
|
285
|
+
* project rather than to a brand, and claiming it for one is the error this
|
|
286
|
+
* function exists to prevent.
|
|
287
|
+
*/
|
|
288
|
+
declare function tenantSources(tenant: string): (name: string) => boolean;
|
|
289
|
+
|
|
223
290
|
/**
|
|
224
291
|
* Sitemap fetch helpers — never throw.
|
|
225
292
|
*
|
|
@@ -231,4 +298,4 @@ declare function decodeChunkId(id: string): {
|
|
|
231
298
|
declare function fetchSitemapIndex(apiUrl: string, revalidate: number): Promise<SitemapIndex>;
|
|
232
299
|
declare function fetchSitemapFeed(apiUrl: string, source: string, cursor: string | null, revalidate: number): Promise<SitemapFeedPage>;
|
|
233
300
|
|
|
234
|
-
export { type CreateDjangoSitemapOptions, type CreateRobotsOptions, type CreateSitemapIndexOptions, type SitemapChunkInfo, type SitemapEntry, type SitemapFeedPage, type SitemapIndex, type SitemapSourceInfo, type StaticRoute, createDjangoSitemap, createRobots, createSitemapIndex, decodeChunkId, encodeChunkId, fetchSitemapFeed, fetchSitemapIndex };
|
|
301
|
+
export { type CreateDjangoSitemapOptions, type CreateRobotsOptions, type CreateSitemapIndexOptions, type SitemapChunkInfo, type SitemapEntry, type SitemapFeedPage, type SitemapIndex, type SitemapSourceInfo, type SourceSelector, type StaticRoute, createDjangoSitemap, createRobots, createSitemapIndex, decodeChunkId, encodeChunkId, fetchSitemapFeed, fetchSitemapIndex, selectSources, sourceFilter, tenantSources };
|
package/dist/sitemap/index.mjs
CHANGED
|
@@ -62,12 +62,40 @@ function decodeChunkId(id) {
|
|
|
62
62
|
return { source, cursor: cursorRaw === "" ? null : cursorRaw };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// src/sitemap/select.ts
|
|
66
|
+
function sourceFilter(select) {
|
|
67
|
+
if (select === void 0) return () => true;
|
|
68
|
+
if (typeof select === "function") return select;
|
|
69
|
+
const allowed = new Set(select);
|
|
70
|
+
return (name) => allowed.has(name);
|
|
71
|
+
}
|
|
72
|
+
function selectSources(index, select) {
|
|
73
|
+
const keep = sourceFilter(select);
|
|
74
|
+
return index.sources.filter((source) => keep(source.name));
|
|
75
|
+
}
|
|
76
|
+
function selectedChunkIds(index, select, encode) {
|
|
77
|
+
const ids = [];
|
|
78
|
+
for (const source of selectSources(index, select)) {
|
|
79
|
+
for (const chunk of source.chunks) {
|
|
80
|
+
ids.push(encode(source.name, chunk.cursor_to));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return ids;
|
|
84
|
+
}
|
|
85
|
+
function tenantSources(tenant) {
|
|
86
|
+
return (name) => {
|
|
87
|
+
const sep = name.lastIndexOf("-");
|
|
88
|
+
return sep !== -1 && name.slice(sep + 1) === tenant;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
65
92
|
// src/sitemap/sitemap.ts
|
|
66
93
|
function createDjangoSitemap(opts) {
|
|
67
94
|
const {
|
|
68
95
|
host,
|
|
69
96
|
apiUrl,
|
|
70
97
|
staticRoutes = [],
|
|
98
|
+
sources,
|
|
71
99
|
indexRevalidate = DEFAULT_INDEX_REVALIDATE,
|
|
72
100
|
feedRevalidate = DEFAULT_FEED_REVALIDATE
|
|
73
101
|
} = opts;
|
|
@@ -76,10 +104,8 @@ function createDjangoSitemap(opts) {
|
|
|
76
104
|
const ids = [{ id: STATIC_ID }];
|
|
77
105
|
if (!apiUrl) return ids;
|
|
78
106
|
const index = await fetchSitemapIndex(apiUrl, indexRevalidate);
|
|
79
|
-
for (const
|
|
80
|
-
|
|
81
|
-
ids.push({ id: encodeChunkId(s.name, c.cursor_to) });
|
|
82
|
-
}
|
|
107
|
+
for (const id of selectedChunkIds(index, sources, encodeChunkId)) {
|
|
108
|
+
ids.push({ id });
|
|
83
109
|
}
|
|
84
110
|
return ids;
|
|
85
111
|
},
|
|
@@ -90,6 +116,7 @@ function createDjangoSitemap(opts) {
|
|
|
90
116
|
}
|
|
91
117
|
if (!apiUrl) return [];
|
|
92
118
|
const { source, cursor } = decodeChunkId(id);
|
|
119
|
+
if (!sourceFilter(sources)(source)) return [];
|
|
93
120
|
const feed = await fetchSitemapFeed(apiUrl, source, cursor, feedRevalidate);
|
|
94
121
|
return feed.entries.map((e) => ({
|
|
95
122
|
url: `${host}${e.loc}`,
|
|
@@ -111,6 +138,7 @@ function createSitemapIndex(opts) {
|
|
|
111
138
|
const {
|
|
112
139
|
host,
|
|
113
140
|
apiUrl,
|
|
141
|
+
sources,
|
|
114
142
|
indexRevalidate = DEFAULT_INDEX_REVALIDATE,
|
|
115
143
|
includeStatic = true
|
|
116
144
|
} = opts;
|
|
@@ -119,11 +147,7 @@ function createSitemapIndex(opts) {
|
|
|
119
147
|
const ids = includeStatic ? [STATIC_ID] : [];
|
|
120
148
|
if (apiUrl) {
|
|
121
149
|
const index = await fetchSitemapIndex(apiUrl, indexRevalidate);
|
|
122
|
-
|
|
123
|
-
for (const c of s.chunks) {
|
|
124
|
-
ids.push(encodeChunkId(s.name, c.cursor_to));
|
|
125
|
-
}
|
|
126
|
-
}
|
|
150
|
+
ids.push(...selectedChunkIds(index, sources, encodeChunkId));
|
|
127
151
|
}
|
|
128
152
|
const lastmod = (/* @__PURE__ */ new Date()).toISOString();
|
|
129
153
|
const body = renderIndex(host, ids, lastmod);
|
|
@@ -173,6 +197,9 @@ export {
|
|
|
173
197
|
decodeChunkId,
|
|
174
198
|
encodeChunkId,
|
|
175
199
|
fetchSitemapFeed,
|
|
176
|
-
fetchSitemapIndex
|
|
200
|
+
fetchSitemapIndex,
|
|
201
|
+
selectSources,
|
|
202
|
+
sourceFilter,
|
|
203
|
+
tenantSources
|
|
177
204
|
};
|
|
178
205
|
//# sourceMappingURL=index.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/sitemap/constants.ts","../../src/sitemap/fetch.ts","../../src/sitemap/ids.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\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 try {\n const r = await fetch(`${apiUrl}/cfg/sitemap/index/`, {\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 r.json()) as SitemapIndex;\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 try {\n const r = await fetch(`${apiUrl}/cfg/sitemap/feed/?${params}`, {\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 r.json()) as SitemapFeedPage;\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 * 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';\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 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 s of index.sources) {\n for (const c of s.chunks) {\n ids.push({ id: encodeChunkId(s.name, c.cursor_to) });\n }\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 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';\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 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 for (const s of index.sources) {\n for (const c of s.chunks) {\n ids.push(encodeChunkId(s.name, c.cursor_to));\n }\n }\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 — 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\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 try {\n const r = await fetch(`${apiUrl}/cfg/sitemap/index/`, {\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 r.json()) as SitemapIndex;\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 try {\n const r = await fetch(`${apiUrl}/cfg/sitemap/feed/?${params}`, {\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 r.json()) as SitemapFeedPage;\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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.545",
|
|
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.545",
|
|
153
|
+
"@djangocfg/i18n": "^2.1.545",
|
|
154
|
+
"@djangocfg/ui-core": "^2.1.545",
|
|
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.545",
|
|
174
|
+
"@djangocfg/i18n": "^2.1.545",
|
|
175
|
+
"@djangocfg/layouts": "^2.1.545",
|
|
176
|
+
"@djangocfg/typescript-config": "^2.1.545",
|
|
177
|
+
"@djangocfg/ui-core": "^2.1.545",
|
|
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
|
@@ -258,6 +258,7 @@ expects: named `generateSitemaps` + default-exported `sitemap`.
|
|
|
258
258
|
| `host` | `string` | (required) | Canonical host (e.g. `https://example.com`). URLs are joined as `${host}${loc}`. |
|
|
259
259
|
| `apiUrl` | `string` | `undefined` | Django backend base URL. Omit to skip the backend feed entirely. |
|
|
260
260
|
| `staticRoutes` | `StaticRoute[]` | `[]` | Frontend-only routes. Auth/account routes should NOT be listed here. |
|
|
261
|
+
| `sources` | `string[] \| (name) => boolean` | every source | Which backend sources this frontend claims. See [Several frontends, one backend](#several-frontends-one-backend). |
|
|
261
262
|
| `indexRevalidate` | `number` | `600` | Seconds — `next: { revalidate }` on the index fetch. |
|
|
262
263
|
| `feedRevalidate` | `number` | `3600` | Seconds — `next: { revalidate }` on each chunk fetch. |
|
|
263
264
|
|
|
@@ -284,6 +285,55 @@ it from `app/robots.ts`.
|
|
|
284
285
|
| `disallow` | `string[]` | `['/account/', '/auth', '/api/']` | Path prefixes to block. |
|
|
285
286
|
| `sitemap` | `string` | `${host}/sitemap.xml` | Override the sitemap pointer. |
|
|
286
287
|
|
|
288
|
+
## Several frontends, one backend
|
|
289
|
+
|
|
290
|
+
`GET /cfg/sitemap/index/` answers for the whole Django project — every
|
|
291
|
+
source any installed app registered. When one backend serves several
|
|
292
|
+
brands, register a source per brand and give each frontend its own:
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
# apps/catalog/sitemap_sources.py
|
|
296
|
+
for portal in ("caribbean", "bali"):
|
|
297
|
+
register(SitemapSource(name=f"properties-{portal}", ...))
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
// apps/web-bali/app/sitemap.ts
|
|
302
|
+
import { createDjangoSitemap, tenantSources } from '@djangocfg/nextjs/sitemap';
|
|
303
|
+
|
|
304
|
+
const { generateSitemaps, sitemap } = createDjangoSitemap({
|
|
305
|
+
host, apiUrl,
|
|
306
|
+
sources: tenantSources('bali'), // properties-bali, communities-bali, …
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
// apps/web-bali/app/sitemap_index.xml/route.ts — the SAME selector
|
|
312
|
+
export const { GET } = createSitemapIndex({
|
|
313
|
+
host, apiUrl,
|
|
314
|
+
sources: tenantSources('bali'),
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Pass the same selector to both. The index lists chunks, `sitemap.ts`
|
|
319
|
+
builds them; a mismatch advertises shards that render empty, and nothing
|
|
320
|
+
fails loudly when it happens.
|
|
321
|
+
|
|
322
|
+
**Why the frontend filters and not the backend.** The index view is
|
|
323
|
+
cached under a key with no tenant dimension, so a `?tenant=` parameter
|
|
324
|
+
would pass every cold-cache test and then serve one brand's chunk list
|
|
325
|
+
to the other an hour later. Source names carry the distinction instead:
|
|
326
|
+
an unknown source 404s, which is a loud failure rather than a silent
|
|
327
|
+
wrong one.
|
|
328
|
+
|
|
329
|
+
`tenantSources` splits the source name on its LAST hyphen and compares
|
|
330
|
+
the tail exactly. Prefer it over `endsWith('-bali')`, which also matches
|
|
331
|
+
a `pricing-plans-premium` for a tenant named `premium`. Names without a
|
|
332
|
+
hyphen never match — an untenanted source belongs to the project, not to
|
|
333
|
+
a brand.
|
|
334
|
+
|
|
335
|
+
Omit `sources` entirely when the backend serves this frontend alone.
|
|
336
|
+
|
|
287
337
|
### Lower-level utilities
|
|
288
338
|
|
|
289
339
|
Exposed for advanced use cases (custom Next route handlers, debugging):
|
|
@@ -294,6 +344,9 @@ import {
|
|
|
294
344
|
fetchSitemapFeed,
|
|
295
345
|
encodeChunkId,
|
|
296
346
|
decodeChunkId,
|
|
347
|
+
selectSources,
|
|
348
|
+
sourceFilter,
|
|
349
|
+
tenantSources,
|
|
297
350
|
} from '@djangocfg/nextjs/sitemap';
|
|
298
351
|
```
|
|
299
352
|
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
} from './constants';
|
|
51
51
|
import { fetchSitemapIndex } from './fetch';
|
|
52
52
|
import { encodeChunkId } from './ids';
|
|
53
|
+
import { selectedChunkIds } from './select';
|
|
53
54
|
|
|
54
55
|
import type { CreateSitemapIndexOptions } from './types';
|
|
55
56
|
|
|
@@ -61,6 +62,7 @@ export function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapInde
|
|
|
61
62
|
const {
|
|
62
63
|
host,
|
|
63
64
|
apiUrl,
|
|
65
|
+
sources,
|
|
64
66
|
indexRevalidate = DEFAULT_INDEX_REVALIDATE,
|
|
65
67
|
includeStatic = true,
|
|
66
68
|
} = opts;
|
|
@@ -71,11 +73,7 @@ export function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapInde
|
|
|
71
73
|
|
|
72
74
|
if (apiUrl) {
|
|
73
75
|
const index = await fetchSitemapIndex(apiUrl, indexRevalidate);
|
|
74
|
-
|
|
75
|
-
for (const c of s.chunks) {
|
|
76
|
-
ids.push(encodeChunkId(s.name, c.cursor_to));
|
|
77
|
-
}
|
|
78
|
-
}
|
|
76
|
+
ids.push(...selectedChunkIds(index, sources, encodeChunkId));
|
|
79
77
|
}
|
|
80
78
|
|
|
81
79
|
const lastmod = new Date().toISOString();
|
package/src/sitemap/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { createDjangoSitemap } from './sitemap';
|
|
|
13
13
|
export { createSitemapIndex } from './index-route';
|
|
14
14
|
export { createRobots } from './robots';
|
|
15
15
|
export { encodeChunkId, decodeChunkId } from './ids';
|
|
16
|
+
export { selectSources, sourceFilter, tenantSources } from './select';
|
|
16
17
|
export { fetchSitemapIndex, fetchSitemapFeed } from './fetch';
|
|
17
18
|
|
|
18
19
|
export type {
|
|
@@ -24,5 +25,6 @@ export type {
|
|
|
24
25
|
CreateDjangoSitemapOptions,
|
|
25
26
|
CreateSitemapIndexOptions,
|
|
26
27
|
CreateRobotsOptions,
|
|
28
|
+
SourceSelector,
|
|
27
29
|
StaticRoute,
|
|
28
30
|
} from './types';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which backend sources a frontend claims.
|
|
3
|
+
*
|
|
4
|
+
* `GET /cfg/sitemap/index/` answers for the whole Django project — every
|
|
5
|
+
* source any installed app registered. One frontend is not always entitled
|
|
6
|
+
* to all of them: a backend serving several brands registers a source per
|
|
7
|
+
* brand (`properties-caribbean`, `properties-bali`), and each brand's app
|
|
8
|
+
* must emit only its own, or it advertises the other's URLs under its own
|
|
9
|
+
* host — every one of them a 404, and the sitemap that carries them is
|
|
10
|
+
* discredited whole.
|
|
11
|
+
*
|
|
12
|
+
* The selector lives here, shared, for the same reason `STATIC_ID` does:
|
|
13
|
+
* `createDjangoSitemap` enumerates the chunks and `createSitemapIndex`
|
|
14
|
+
* enumerates them again for the index document. If the two applied
|
|
15
|
+
* different filters, the index would advertise shards the chunk route
|
|
16
|
+
* refuses to build — and that mismatch is invisible until a crawler reads
|
|
17
|
+
* both. One function, called by both, cannot drift.
|
|
18
|
+
*
|
|
19
|
+
* Source NAMES are the contract. The module ships no notion of a brand or
|
|
20
|
+
* a tenant, and it should not: the backend chose what its source names
|
|
21
|
+
* mean, and only the app knows which of them are its own.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { SitemapIndex, SitemapSourceInfo, SourceSelector } from './types';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A predicate from whatever the caller passed.
|
|
28
|
+
*
|
|
29
|
+
* Omitted means every source — the single-tenant default, and what every
|
|
30
|
+
* caller before this option existed already relied on.
|
|
31
|
+
*/
|
|
32
|
+
export function sourceFilter(select: SourceSelector | undefined): (name: string) => boolean {
|
|
33
|
+
if (select === undefined) return () => true;
|
|
34
|
+
if (typeof select === 'function') return select;
|
|
35
|
+
const allowed = new Set(select);
|
|
36
|
+
return (name) => allowed.has(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The index's sources, narrowed to those the caller claims. */
|
|
40
|
+
export function selectSources(
|
|
41
|
+
index: SitemapIndex,
|
|
42
|
+
select: SourceSelector | undefined,
|
|
43
|
+
): SitemapSourceInfo[] {
|
|
44
|
+
const keep = sourceFilter(select);
|
|
45
|
+
return index.sources.filter((source) => keep(source.name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every chunk id the selected sources produce, in index order.
|
|
50
|
+
*
|
|
51
|
+
* The one place `(source, chunk)` becomes an id, so the chunk route and the
|
|
52
|
+
* index document enumerate the same set by construction.
|
|
53
|
+
*/
|
|
54
|
+
export function selectedChunkIds(
|
|
55
|
+
index: SitemapIndex,
|
|
56
|
+
select: SourceSelector | undefined,
|
|
57
|
+
encode: (source: string, cursor: string | null) => string,
|
|
58
|
+
): string[] {
|
|
59
|
+
const ids: string[] = [];
|
|
60
|
+
for (const source of selectSources(index, select)) {
|
|
61
|
+
for (const chunk of source.chunks) {
|
|
62
|
+
ids.push(encode(source.name, chunk.cursor_to));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return ids;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A selector for one tenant of a multi-brand backend.
|
|
70
|
+
*
|
|
71
|
+
* Matches the `<source>-<tenant>` names that a backend registering per
|
|
72
|
+
* tenant emits. It splits on the LAST hyphen and compares the tail exactly,
|
|
73
|
+
* rather than testing a suffix: `endsWith('-bali')` is correct for
|
|
74
|
+
* `properties-bali` but the same shape reads `pricing-plans-premium` as
|
|
75
|
+
* belonging to a `premium` tenant, and a substring match is not a contract.
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* sources: tenantSources('bali') // properties-bali, communities-bali, …
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* Sources with no hyphen never match: an untenanted source belongs to the
|
|
82
|
+
* project rather than to a brand, and claiming it for one is the error this
|
|
83
|
+
* function exists to prevent.
|
|
84
|
+
*/
|
|
85
|
+
export function tenantSources(tenant: string): (name: string) => boolean {
|
|
86
|
+
return (name) => {
|
|
87
|
+
const sep = name.lastIndexOf('-');
|
|
88
|
+
return sep !== -1 && name.slice(sep + 1) === tenant;
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/sitemap/sitemap.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './constants';
|
|
38
38
|
import { fetchSitemapFeed, fetchSitemapIndex } from './fetch';
|
|
39
39
|
import { decodeChunkId, encodeChunkId } from './ids';
|
|
40
|
+
import { selectedChunkIds, sourceFilter } from './select';
|
|
40
41
|
|
|
41
42
|
import type { CreateDjangoSitemapOptions, StaticRoute } from './types';
|
|
42
43
|
|
|
@@ -50,6 +51,7 @@ export function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapAp
|
|
|
50
51
|
host,
|
|
51
52
|
apiUrl,
|
|
52
53
|
staticRoutes = [],
|
|
54
|
+
sources,
|
|
53
55
|
indexRevalidate = DEFAULT_INDEX_REVALIDATE,
|
|
54
56
|
feedRevalidate = DEFAULT_FEED_REVALIDATE,
|
|
55
57
|
} = opts;
|
|
@@ -59,10 +61,8 @@ export function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapAp
|
|
|
59
61
|
const ids: Array<{ id: string }> = [{ id: STATIC_ID }];
|
|
60
62
|
if (!apiUrl) return ids;
|
|
61
63
|
const index = await fetchSitemapIndex(apiUrl, indexRevalidate);
|
|
62
|
-
for (const
|
|
63
|
-
|
|
64
|
-
ids.push({ id: encodeChunkId(s.name, c.cursor_to) });
|
|
65
|
-
}
|
|
64
|
+
for (const id of selectedChunkIds(index, sources, encodeChunkId)) {
|
|
65
|
+
ids.push({ id });
|
|
66
66
|
}
|
|
67
67
|
return ids;
|
|
68
68
|
},
|
|
@@ -77,6 +77,12 @@ export function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapAp
|
|
|
77
77
|
if (!apiUrl) return [];
|
|
78
78
|
|
|
79
79
|
const { source, cursor } = decodeChunkId(id);
|
|
80
|
+
// A chunk this app does not claim renders empty rather than fetching.
|
|
81
|
+
// `generateSitemaps` never mints such an id, but the route stays
|
|
82
|
+
// reachable by hand, and serving another tenant's URLs from a guessed
|
|
83
|
+
// address would undo the filter above.
|
|
84
|
+
if (!sourceFilter(sources)(source)) return [];
|
|
85
|
+
|
|
80
86
|
const feed = await fetchSitemapFeed(apiUrl, source, cursor, feedRevalidate);
|
|
81
87
|
return feed.entries.map((e) => ({
|
|
82
88
|
url: `${host}${e.loc}`,
|
package/src/sitemap/types.ts
CHANGED
|
@@ -44,6 +44,16 @@ export interface SitemapFeedPage {
|
|
|
44
44
|
|
|
45
45
|
// ── Public option types ─────────────────────────────────────────────────
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Which backend sources a frontend claims: a list of names, or a predicate.
|
|
49
|
+
*
|
|
50
|
+
* Omit for every source in the index — right whenever the backend serves
|
|
51
|
+
* this frontend alone. Pass one when several frontends share a backend, so
|
|
52
|
+
* each emits only the URLs its own host can resolve. See `tenantSources`
|
|
53
|
+
* for the `<source>-<tenant>` convention.
|
|
54
|
+
*/
|
|
55
|
+
export type SourceSelector = string[] | ((name: string) => boolean);
|
|
56
|
+
|
|
47
57
|
export interface StaticRoute {
|
|
48
58
|
path: string;
|
|
49
59
|
changeFrequency?: ChangeFreq;
|
|
@@ -63,6 +73,10 @@ export interface CreateDjangoSitemapOptions {
|
|
|
63
73
|
* pages should NOT be listed here. */
|
|
64
74
|
staticRoutes?: StaticRoute[];
|
|
65
75
|
|
|
76
|
+
/** Backend sources to emit. Default: every source the index reports.
|
|
77
|
+
* Keep in step with the same app's `createSitemapIndex`. */
|
|
78
|
+
sources?: SourceSelector;
|
|
79
|
+
|
|
66
80
|
/** Index revalidate window in seconds. Default: 600. */
|
|
67
81
|
indexRevalidate?: number;
|
|
68
82
|
|
|
@@ -83,6 +97,11 @@ export interface CreateSitemapIndexOptions {
|
|
|
83
97
|
* and the emitted XML's `s-maxage`. Default: 600. */
|
|
84
98
|
indexRevalidate?: number;
|
|
85
99
|
|
|
100
|
+
/** Backend sources to list. Default: every source the index reports.
|
|
101
|
+
* MUST match the same app's `createDjangoSitemap` — a mismatch
|
|
102
|
+
* advertises chunks the sitemap route will not build. */
|
|
103
|
+
sources?: SourceSelector;
|
|
104
|
+
|
|
86
105
|
/** Include the `static` chunk id at the top of the index. Turn off if
|
|
87
106
|
* the app's `sitemap.ts` opts out of `staticRoutes`. Default: true. */
|
|
88
107
|
includeStatic?: boolean;
|