@djangocfg/nextjs 2.1.415 → 2.1.417

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.
@@ -56,11 +56,27 @@ interface CreateDjangoSitemapOptions {
56
56
  /** Feed (chunk) revalidate window in seconds. Default: 3600. */
57
57
  feedRevalidate?: number;
58
58
  }
59
+ interface CreateSitemapIndexOptions {
60
+ /** Canonical site host, e.g. `https://vamcar.com`. Chunk URLs in the
61
+ * index are emitted as `${host}/sitemap/<id>.xml`. */
62
+ host: string;
63
+ /** Django backend base URL. When omitted, the index lists only the
64
+ * static chunk (mirrors `createDjangoSitemap` without `apiUrl`). */
65
+ apiUrl?: string;
66
+ /** Revalidate window in seconds for both the upstream `index/` fetch
67
+ * and the emitted XML's `s-maxage`. Default: 600. */
68
+ indexRevalidate?: number;
69
+ /** Include the `static` chunk id at the top of the index. Turn off if
70
+ * the app's `sitemap.ts` opts out of `staticRoutes`. Default: true. */
71
+ includeStatic?: boolean;
72
+ }
59
73
  interface CreateRobotsOptions {
60
74
  host: string;
61
75
  /** Disallow patterns. Default: ['/account/', '/auth', '/api/']. */
62
76
  disallow?: string[];
63
- /** Override sitemap URL. Default: `${host}/sitemap.xml`. */
77
+ /** Override sitemap URL. Default: `${host}/sitemap.xml`.
78
+ * When using `createDjangoSitemap` + `createSitemapIndex`, pass
79
+ * `${host}/sitemap_index.xml` — see `createSitemapIndex` JSDoc. */
64
80
  sitemap?: string;
65
81
  }
66
82
 
@@ -104,6 +120,57 @@ interface SitemapApi {
104
120
  }
105
121
  declare function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapApi;
106
122
 
123
+ /**
124
+ * createSitemapIndex — Route Handler factory for the sitemap-index XML.
125
+ *
126
+ * Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`
127
+ * but does NOT emit a single sitemap-index document. Without one,
128
+ * robots.txt + crawlers point at a 404 and never discover the chunks.
129
+ * This factory closes that gap: it returns a `GET` Route Handler that
130
+ * mints a `<sitemapindex>` XML listing every chunk the matching
131
+ * `createDjangoSitemap` would produce.
132
+ *
133
+ * Mount it at **`app/sitemap_index.xml/route.ts`** — NOT `sitemap.xml`.
134
+ * `app/sitemap.ts` (where `createDjangoSitemap` lives) already owns
135
+ * `/sitemap.xml` in Next.js's metadata routing, even when its output is
136
+ * fan-out via `generateSitemaps()` and the singular URL serves 404.
137
+ * Putting a route handler at `app/sitemap.xml/route.ts` throws at build
138
+ * time: *Conflicting route and metadata at /sitemap.xml*.
139
+ *
140
+ * Usage — the full pattern with the matching chunk + robots factories:
141
+ *
142
+ * ```ts
143
+ * // apps/<app>/app/sitemap.ts — chunks
144
+ * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';
145
+ * const { generateSitemaps, sitemap } = createDjangoSitemap({ ... });
146
+ * export { generateSitemaps, sitemap as default };
147
+ *
148
+ * // apps/<app>/app/sitemap_index.xml/route.ts — the index
149
+ * import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';
150
+ * export const { GET } = createSitemapIndex({
151
+ * host: process.env.NEXT_PUBLIC_SITE_URL!,
152
+ * apiUrl: process.env.NEXT_PUBLIC_API_URL!,
153
+ * });
154
+ * export const revalidate = 600;
155
+ *
156
+ * // apps/<app>/app/robots.ts — point crawlers at the index URL above
157
+ * import { createRobots } from '@djangocfg/nextjs/sitemap';
158
+ * const host = process.env.NEXT_PUBLIC_SITE_URL!;
159
+ * export default createRobots({
160
+ * host,
161
+ * sitemap: `${host}/sitemap_index.xml`,
162
+ * });
163
+ * ```
164
+ *
165
+ * Keep `host` / `apiUrl` in sync with the same app's `app/sitemap.ts`
166
+ * — the index just enumerates the chunks the other file emits.
167
+ */
168
+
169
+ interface SitemapIndexRoute {
170
+ GET: () => Promise<Response>;
171
+ }
172
+ declare function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapIndexRoute;
173
+
107
174
  /**
108
175
  * createRobots — factory for Next.js `app/robots.ts`.
109
176
  *
@@ -111,15 +178,27 @@ declare function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapA
111
178
  * defaults cover the common cases (auth, account, API surface) — override
112
179
  * per app if needed.
113
180
  *
181
+ * The `sitemap` default `${host}/sitemap.xml` only makes sense for the
182
+ * single-file case. When the app uses `createDjangoSitemap`
183
+ * (`generateSitemaps()` → chunked output) **plus** `createSitemapIndex`
184
+ * — the canonical pairing — pass an explicit
185
+ * `sitemap: \`${host}/sitemap_index.xml\`` to point crawlers at the
186
+ * index. The default `/sitemap.xml` will serve a 404 in that setup
187
+ * (Next.js reserves it for the metadata route but doesn't emit content
188
+ * there).
189
+ *
114
190
  * Usage:
115
191
  *
116
192
  * ```ts
117
193
  * // apps/<app>/app/robots.ts
118
194
  * import { createRobots } from '@djangocfg/nextjs/sitemap';
119
195
  *
196
+ * const host = process.env.NEXT_PUBLIC_SITE_URL!;
197
+ *
120
198
  * export default createRobots({
121
- * host: process.env.NEXT_PUBLIC_SITE_URL!,
199
+ * host,
122
200
  * disallow: ['/account/', '/auth', '/api/', '/apix/'],
201
+ * sitemap: `${host}/sitemap_index.xml`, // matches createSitemapIndex
123
202
  * });
124
203
  * ```
125
204
  */
@@ -152,4 +231,4 @@ declare function decodeChunkId(id: string): {
152
231
  declare function fetchSitemapIndex(apiUrl: string, revalidate: number): Promise<SitemapIndex>;
153
232
  declare function fetchSitemapFeed(apiUrl: string, source: string, cursor: string | null, revalidate: number): Promise<SitemapFeedPage>;
154
233
 
155
- export { type CreateDjangoSitemapOptions, type CreateRobotsOptions, type SitemapChunkInfo, type SitemapEntry, type SitemapFeedPage, type SitemapIndex, type SitemapSourceInfo, type StaticRoute, createDjangoSitemap, createRobots, decodeChunkId, encodeChunkId, fetchSitemapFeed, fetchSitemapIndex };
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 };
@@ -1,3 +1,8 @@
1
+ // src/sitemap/constants.ts
2
+ var STATIC_ID = "static";
3
+ var DEFAULT_INDEX_REVALIDATE = 600;
4
+ var DEFAULT_FEED_REVALIDATE = 3600;
5
+
1
6
  // src/sitemap/fetch.ts
2
7
  var EMPTY_INDEX = {
3
8
  sources: [],
@@ -58,9 +63,6 @@ function decodeChunkId(id) {
58
63
  }
59
64
 
60
65
  // src/sitemap/sitemap.ts
61
- var STATIC_ID = "static";
62
- var DEFAULT_INDEX_REVALIDATE = 600;
63
- var DEFAULT_FEED_REVALIDATE = 3600;
64
66
  function createDjangoSitemap(opts) {
65
67
  const {
66
68
  host,
@@ -104,6 +106,56 @@ function renderStatic(host, routes) {
104
106
  }));
105
107
  }
106
108
 
109
+ // src/sitemap/index-route.ts
110
+ function createSitemapIndex(opts) {
111
+ const {
112
+ host,
113
+ apiUrl,
114
+ indexRevalidate = DEFAULT_INDEX_REVALIDATE,
115
+ includeStatic = true
116
+ } = opts;
117
+ return {
118
+ async GET() {
119
+ const ids = includeStatic ? [STATIC_ID] : [];
120
+ if (apiUrl) {
121
+ const index = await fetchSitemapIndex(apiUrl, indexRevalidate);
122
+ for (const s of index.sources) {
123
+ for (const c of s.chunks) {
124
+ ids.push(encodeChunkId(s.name, c.cursor_to));
125
+ }
126
+ }
127
+ }
128
+ const lastmod = (/* @__PURE__ */ new Date()).toISOString();
129
+ const body = renderIndex(host, ids, lastmod);
130
+ return new Response(body, {
131
+ status: 200,
132
+ headers: {
133
+ "content-type": "application/xml; charset=utf-8",
134
+ // Mirror the per-chunk revalidate so a cached CDN edge expires
135
+ // at the same cadence as the index it points at.
136
+ "cache-control": `public, s-maxage=${indexRevalidate}, stale-while-revalidate=${indexRevalidate}`
137
+ }
138
+ });
139
+ }
140
+ };
141
+ }
142
+ function renderIndex(host, ids, lastmod) {
143
+ const lines = [];
144
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
145
+ lines.push('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
146
+ for (const id of ids) {
147
+ lines.push(" <sitemap>");
148
+ lines.push(` <loc>${escapeXml(`${host}/sitemap/${id}.xml`)}</loc>`);
149
+ lines.push(` <lastmod>${lastmod}</lastmod>`);
150
+ lines.push(" </sitemap>");
151
+ }
152
+ lines.push("</sitemapindex>");
153
+ return lines.join("\n");
154
+ }
155
+ function escapeXml(s) {
156
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
157
+ }
158
+
107
159
  // src/sitemap/robots.ts
108
160
  var DEFAULT_DISALLOW = ["/account/", "/auth", "/api/"];
109
161
  function createRobots(opts) {
@@ -117,6 +169,7 @@ function createRobots(opts) {
117
169
  export {
118
170
  createDjangoSitemap,
119
171
  createRobots,
172
+ createSitemapIndex,
120
173
  decodeChunkId,
121
174
  encodeChunkId,
122
175
  fetchSitemapFeed,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sitemap/fetch.ts","../../src/sitemap/ids.ts","../../src/sitemap/sitemap.ts","../../src/sitemap/robots.ts"],"sourcesContent":["/**\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 { fetchSitemapFeed, fetchSitemapIndex } from './fetch';\nimport { decodeChunkId, encodeChunkId } from './ids';\n\nimport type { CreateDjangoSitemapOptions, StaticRoute } from './types';\n\nconst STATIC_ID = 'static';\nconst DEFAULT_INDEX_REVALIDATE = 600;\nconst DEFAULT_FEED_REVALIDATE = 3600;\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 * 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 * Usage:\n *\n * ```ts\n * // apps/<app>/app/robots.ts\n * import { createRobots } from '@djangocfg/nextjs/sitemap';\n *\n * export default createRobots({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * disallow: ['/account/', '/auth', '/api/', '/apix/'],\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/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, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;');\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.415",
3
+ "version": "2.1.417",
4
4
  "description": "Next.js server utilities: sitemap, health, OG images, contact forms, navigation, config",
5
5
  "keywords": [
6
6
  "nextjs",
@@ -143,9 +143,9 @@
143
143
  "ai-docs": "tsx src/ai/cli.ts"
144
144
  },
145
145
  "peerDependencies": {
146
- "@djangocfg/i18n": "^2.1.415",
147
- "@djangocfg/monitor": "^2.1.415",
148
- "@djangocfg/ui-core": "^2.1.415",
146
+ "@djangocfg/i18n": "^2.1.417",
147
+ "@djangocfg/monitor": "^2.1.417",
148
+ "@djangocfg/ui-core": "^2.1.417",
149
149
  "next": "^16.2.2"
150
150
  },
151
151
  "peerDependenciesMeta": {
@@ -167,11 +167,11 @@
167
167
  "serwist": "^9.2.3"
168
168
  },
169
169
  "devDependencies": {
170
- "@djangocfg/i18n": "^2.1.415",
171
- "@djangocfg/monitor": "^2.1.415",
172
- "@djangocfg/ui-core": "^2.1.415",
173
- "@djangocfg/layouts": "^2.1.415",
174
- "@djangocfg/typescript-config": "^2.1.415",
170
+ "@djangocfg/i18n": "^2.1.417",
171
+ "@djangocfg/monitor": "^2.1.417",
172
+ "@djangocfg/ui-core": "^2.1.417",
173
+ "@djangocfg/layouts": "^2.1.417",
174
+ "@djangocfg/typescript-config": "^2.1.417",
175
175
  "@types/node": "^25.2.3",
176
176
  "@types/react": "^19.2.15",
177
177
  "@types/react-dom": "^19.2.3",
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared sitemap constants.
3
+ *
4
+ * Pulled out so both the chunk factory (`createDjangoSitemap`) and the
5
+ * index Route Handler (`createSitemapIndex`) agree on the id of the
6
+ * frontend-only static-routes chunk.
7
+ */
8
+
9
+ export const STATIC_ID = 'static';
10
+ export const DEFAULT_INDEX_REVALIDATE = 600;
11
+ export const DEFAULT_FEED_REVALIDATE = 3600;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * createSitemapIndex — Route Handler factory for the sitemap-index XML.
3
+ *
4
+ * Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`
5
+ * but does NOT emit a single sitemap-index document. Without one,
6
+ * robots.txt + crawlers point at a 404 and never discover the chunks.
7
+ * This factory closes that gap: it returns a `GET` Route Handler that
8
+ * mints a `<sitemapindex>` XML listing every chunk the matching
9
+ * `createDjangoSitemap` would produce.
10
+ *
11
+ * Mount it at **`app/sitemap_index.xml/route.ts`** — NOT `sitemap.xml`.
12
+ * `app/sitemap.ts` (where `createDjangoSitemap` lives) already owns
13
+ * `/sitemap.xml` in Next.js's metadata routing, even when its output is
14
+ * fan-out via `generateSitemaps()` and the singular URL serves 404.
15
+ * Putting a route handler at `app/sitemap.xml/route.ts` throws at build
16
+ * time: *Conflicting route and metadata at /sitemap.xml*.
17
+ *
18
+ * Usage — the full pattern with the matching chunk + robots factories:
19
+ *
20
+ * ```ts
21
+ * // apps/<app>/app/sitemap.ts — chunks
22
+ * import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';
23
+ * const { generateSitemaps, sitemap } = createDjangoSitemap({ ... });
24
+ * export { generateSitemaps, sitemap as default };
25
+ *
26
+ * // apps/<app>/app/sitemap_index.xml/route.ts — the index
27
+ * import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';
28
+ * export const { GET } = createSitemapIndex({
29
+ * host: process.env.NEXT_PUBLIC_SITE_URL!,
30
+ * apiUrl: process.env.NEXT_PUBLIC_API_URL!,
31
+ * });
32
+ * export const revalidate = 600;
33
+ *
34
+ * // apps/<app>/app/robots.ts — point crawlers at the index URL above
35
+ * import { createRobots } from '@djangocfg/nextjs/sitemap';
36
+ * const host = process.env.NEXT_PUBLIC_SITE_URL!;
37
+ * export default createRobots({
38
+ * host,
39
+ * sitemap: `${host}/sitemap_index.xml`,
40
+ * });
41
+ * ```
42
+ *
43
+ * Keep `host` / `apiUrl` in sync with the same app's `app/sitemap.ts`
44
+ * — the index just enumerates the chunks the other file emits.
45
+ */
46
+
47
+ import {
48
+ DEFAULT_INDEX_REVALIDATE,
49
+ STATIC_ID,
50
+ } from './constants';
51
+ import { fetchSitemapIndex } from './fetch';
52
+ import { encodeChunkId } from './ids';
53
+
54
+ import type { CreateSitemapIndexOptions } from './types';
55
+
56
+ interface SitemapIndexRoute {
57
+ GET: () => Promise<Response>;
58
+ }
59
+
60
+ export function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapIndexRoute {
61
+ const {
62
+ host,
63
+ apiUrl,
64
+ indexRevalidate = DEFAULT_INDEX_REVALIDATE,
65
+ includeStatic = true,
66
+ } = opts;
67
+
68
+ return {
69
+ async GET() {
70
+ const ids: string[] = includeStatic ? [STATIC_ID] : [];
71
+
72
+ if (apiUrl) {
73
+ const index = await fetchSitemapIndex(apiUrl, indexRevalidate);
74
+ for (const s of index.sources) {
75
+ for (const c of s.chunks) {
76
+ ids.push(encodeChunkId(s.name, c.cursor_to));
77
+ }
78
+ }
79
+ }
80
+
81
+ const lastmod = new Date().toISOString();
82
+ const body = renderIndex(host, ids, lastmod);
83
+
84
+ return new Response(body, {
85
+ status: 200,
86
+ headers: {
87
+ 'content-type': 'application/xml; charset=utf-8',
88
+ // Mirror the per-chunk revalidate so a cached CDN edge expires
89
+ // at the same cadence as the index it points at.
90
+ 'cache-control': `public, s-maxage=${indexRevalidate}, stale-while-revalidate=${indexRevalidate}`,
91
+ },
92
+ });
93
+ },
94
+ };
95
+ }
96
+
97
+ function renderIndex(host: string, ids: string[], lastmod: string): string {
98
+ const lines: string[] = [];
99
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
100
+ lines.push('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
101
+ for (const id of ids) {
102
+ lines.push(' <sitemap>');
103
+ lines.push(` <loc>${escapeXml(`${host}/sitemap/${id}.xml`)}</loc>`);
104
+ lines.push(` <lastmod>${lastmod}</lastmod>`);
105
+ lines.push(' </sitemap>');
106
+ }
107
+ lines.push('</sitemapindex>');
108
+ return lines.join('\n');
109
+ }
110
+
111
+ function escapeXml(s: string): string {
112
+ return s
113
+ .replace(/&/g, '&amp;')
114
+ .replace(/</g, '&lt;')
115
+ .replace(/>/g, '&gt;')
116
+ .replace(/"/g, '&quot;')
117
+ .replace(/'/g, '&apos;');
118
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  export { createDjangoSitemap } from './sitemap';
13
+ export { createSitemapIndex } from './index-route';
13
14
  export { createRobots } from './robots';
14
15
  export { encodeChunkId, decodeChunkId } from './ids';
15
16
  export { fetchSitemapIndex, fetchSitemapFeed } from './fetch';
@@ -21,6 +22,7 @@ export type {
21
22
  SitemapIndex,
22
23
  SitemapSourceInfo,
23
24
  CreateDjangoSitemapOptions,
25
+ CreateSitemapIndexOptions,
24
26
  CreateRobotsOptions,
25
27
  StaticRoute,
26
28
  } from './types';
@@ -5,15 +5,27 @@
5
5
  * defaults cover the common cases (auth, account, API surface) — override
6
6
  * per app if needed.
7
7
  *
8
+ * The `sitemap` default `${host}/sitemap.xml` only makes sense for the
9
+ * single-file case. When the app uses `createDjangoSitemap`
10
+ * (`generateSitemaps()` → chunked output) **plus** `createSitemapIndex`
11
+ * — the canonical pairing — pass an explicit
12
+ * `sitemap: \`${host}/sitemap_index.xml\`` to point crawlers at the
13
+ * index. The default `/sitemap.xml` will serve a 404 in that setup
14
+ * (Next.js reserves it for the metadata route but doesn't emit content
15
+ * there).
16
+ *
8
17
  * Usage:
9
18
  *
10
19
  * ```ts
11
20
  * // apps/<app>/app/robots.ts
12
21
  * import { createRobots } from '@djangocfg/nextjs/sitemap';
13
22
  *
23
+ * const host = process.env.NEXT_PUBLIC_SITE_URL!;
24
+ *
14
25
  * export default createRobots({
15
- * host: process.env.NEXT_PUBLIC_SITE_URL!,
26
+ * host,
16
27
  * disallow: ['/account/', '/auth', '/api/', '/apix/'],
28
+ * sitemap: `${host}/sitemap_index.xml`, // matches createSitemapIndex
17
29
  * });
18
30
  * ```
19
31
  */
@@ -30,15 +30,16 @@
30
30
 
31
31
  import type { MetadataRoute } from 'next';
32
32
 
33
+ import {
34
+ DEFAULT_FEED_REVALIDATE,
35
+ DEFAULT_INDEX_REVALIDATE,
36
+ STATIC_ID,
37
+ } from './constants';
33
38
  import { fetchSitemapFeed, fetchSitemapIndex } from './fetch';
34
39
  import { decodeChunkId, encodeChunkId } from './ids';
35
40
 
36
41
  import type { CreateDjangoSitemapOptions, StaticRoute } from './types';
37
42
 
38
- const STATIC_ID = 'static';
39
- const DEFAULT_INDEX_REVALIDATE = 600;
40
- const DEFAULT_FEED_REVALIDATE = 3600;
41
-
42
43
  interface SitemapApi {
43
44
  generateSitemaps: () => Promise<Array<{ id: string }>>;
44
45
  sitemap: (props: { id: Promise<string> }) => Promise<MetadataRoute.Sitemap>;
@@ -70,10 +70,30 @@ export interface CreateDjangoSitemapOptions {
70
70
  feedRevalidate?: number;
71
71
  }
72
72
 
73
+ export interface CreateSitemapIndexOptions {
74
+ /** Canonical site host, e.g. `https://vamcar.com`. Chunk URLs in the
75
+ * index are emitted as `${host}/sitemap/<id>.xml`. */
76
+ host: string;
77
+
78
+ /** Django backend base URL. When omitted, the index lists only the
79
+ * static chunk (mirrors `createDjangoSitemap` without `apiUrl`). */
80
+ apiUrl?: string;
81
+
82
+ /** Revalidate window in seconds for both the upstream `index/` fetch
83
+ * and the emitted XML's `s-maxage`. Default: 600. */
84
+ indexRevalidate?: number;
85
+
86
+ /** Include the `static` chunk id at the top of the index. Turn off if
87
+ * the app's `sitemap.ts` opts out of `staticRoutes`. Default: true. */
88
+ includeStatic?: boolean;
89
+ }
90
+
73
91
  export interface CreateRobotsOptions {
74
92
  host: string;
75
93
  /** Disallow patterns. Default: ['/account/', '/auth', '/api/']. */
76
94
  disallow?: string[];
77
- /** Override sitemap URL. Default: `${host}/sitemap.xml`. */
95
+ /** Override sitemap URL. Default: `${host}/sitemap.xml`.
96
+ * When using `createDjangoSitemap` + `createSitemapIndex`, pass
97
+ * `${host}/sitemap_index.xml` — see `createSitemapIndex` JSDoc. */
78
98
  sitemap?: string;
79
99
  }