@djangocfg/nextjs 2.1.413 → 2.1.416
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/config/index.mjs +1 -1
- package/dist/config/index.mjs.map +1 -1
- package/dist/i18n/routing.d.mts +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +57 -4
- package/dist/index.mjs.map +1 -1
- package/dist/sitemap/index.d.mts +48 -1
- package/dist/sitemap/index.mjs +56 -3
- package/dist/sitemap/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/sitemap/constants.ts +11 -0
- package/src/sitemap/index-route.ts +100 -0
- package/src/sitemap/index.ts +2 -0
- package/src/sitemap/sitemap.ts +5 -4
- package/src/sitemap/types.ts +18 -0
package/dist/sitemap/index.d.mts
CHANGED
|
@@ -56,6 +56,20 @@ 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/']. */
|
|
@@ -104,6 +118,39 @@ interface SitemapApi {
|
|
|
104
118
|
}
|
|
105
119
|
declare function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapApi;
|
|
106
120
|
|
|
121
|
+
/**
|
|
122
|
+
* createSitemapIndex — Route Handler factory for `app/sitemap.xml/route.ts`.
|
|
123
|
+
*
|
|
124
|
+
* Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`
|
|
125
|
+
* but does NOT emit a single `/sitemap.xml` index. Without one,
|
|
126
|
+
* robots.txt + crawlers point at a 404 and never discover the chunks.
|
|
127
|
+
* This factory closes that gap: it returns a `GET` Route Handler that
|
|
128
|
+
* mints a `<sitemapindex>` XML listing every chunk the matching
|
|
129
|
+
* `createDjangoSitemap` would produce.
|
|
130
|
+
*
|
|
131
|
+
* Usage:
|
|
132
|
+
*
|
|
133
|
+
* ```ts
|
|
134
|
+
* // apps/<app>/app/sitemap.xml/route.ts
|
|
135
|
+
* import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';
|
|
136
|
+
*
|
|
137
|
+
* export const { GET } = createSitemapIndex({
|
|
138
|
+
* host: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
139
|
+
* apiUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
140
|
+
* });
|
|
141
|
+
*
|
|
142
|
+
* export const revalidate = 600;
|
|
143
|
+
* ```
|
|
144
|
+
*
|
|
145
|
+
* Keep `host` / `apiUrl` in sync with the same app's `app/sitemap.ts`
|
|
146
|
+
* — the index just enumerates the chunks the other file emits.
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
interface SitemapIndexRoute {
|
|
150
|
+
GET: () => Promise<Response>;
|
|
151
|
+
}
|
|
152
|
+
declare function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapIndexRoute;
|
|
153
|
+
|
|
107
154
|
/**
|
|
108
155
|
* createRobots — factory for Next.js `app/robots.ts`.
|
|
109
156
|
*
|
|
@@ -152,4 +199,4 @@ declare function decodeChunkId(id: string): {
|
|
|
152
199
|
declare function fetchSitemapIndex(apiUrl: string, revalidate: number): Promise<SitemapIndex>;
|
|
153
200
|
declare function fetchSitemapFeed(apiUrl: string, source: string, cursor: string | null, revalidate: number): Promise<SitemapFeedPage>;
|
|
154
201
|
|
|
155
|
-
export { type CreateDjangoSitemapOptions, type CreateRobotsOptions, type SitemapChunkInfo, type SitemapEntry, type SitemapFeedPage, type SitemapIndex, type SitemapSourceInfo, type StaticRoute, createDjangoSitemap, createRobots, decodeChunkId, encodeChunkId, fetchSitemapFeed, fetchSitemapIndex };
|
|
202
|
+
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 };
|
package/dist/sitemap/index.mjs
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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 `app/sitemap.xml/route.ts`.\n *\n * Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`\n * but does NOT emit a single `/sitemap.xml` index. 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 * Usage:\n *\n * ```ts\n * // apps/<app>/app/sitemap.xml/route.ts\n * import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';\n *\n * export const { GET } = createSitemapIndex({\n * host: process.env.NEXT_PUBLIC_SITE_URL!,\n * apiUrl: process.env.NEXT_PUBLIC_API_URL!,\n * });\n *\n * export const revalidate = 600;\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 * 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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/nextjs",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.416",
|
|
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.
|
|
147
|
-
"@djangocfg/monitor": "^2.1.
|
|
148
|
-
"@djangocfg/ui-core": "^2.1.
|
|
146
|
+
"@djangocfg/i18n": "^2.1.416",
|
|
147
|
+
"@djangocfg/monitor": "^2.1.416",
|
|
148
|
+
"@djangocfg/ui-core": "^2.1.416",
|
|
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.
|
|
171
|
-
"@djangocfg/monitor": "^2.1.
|
|
172
|
-
"@djangocfg/ui-core": "^2.1.
|
|
173
|
-
"@djangocfg/layouts": "^2.1.
|
|
174
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
170
|
+
"@djangocfg/i18n": "^2.1.416",
|
|
171
|
+
"@djangocfg/monitor": "^2.1.416",
|
|
172
|
+
"@djangocfg/ui-core": "^2.1.416",
|
|
173
|
+
"@djangocfg/layouts": "^2.1.416",
|
|
174
|
+
"@djangocfg/typescript-config": "^2.1.416",
|
|
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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createSitemapIndex — Route Handler factory for `app/sitemap.xml/route.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Next.js's `generateSitemaps()` API ships chunks at `/sitemap/<id>.xml`
|
|
5
|
+
* but does NOT emit a single `/sitemap.xml` index. 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
|
+
* Usage:
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* // apps/<app>/app/sitemap.xml/route.ts
|
|
15
|
+
* import { createSitemapIndex } from '@djangocfg/nextjs/sitemap';
|
|
16
|
+
*
|
|
17
|
+
* export const { GET } = createSitemapIndex({
|
|
18
|
+
* host: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
19
|
+
* apiUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* export const revalidate = 600;
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Keep `host` / `apiUrl` in sync with the same app's `app/sitemap.ts`
|
|
26
|
+
* — the index just enumerates the chunks the other file emits.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_INDEX_REVALIDATE,
|
|
31
|
+
STATIC_ID,
|
|
32
|
+
} from './constants';
|
|
33
|
+
import { fetchSitemapIndex } from './fetch';
|
|
34
|
+
import { encodeChunkId } from './ids';
|
|
35
|
+
|
|
36
|
+
import type { CreateSitemapIndexOptions } from './types';
|
|
37
|
+
|
|
38
|
+
interface SitemapIndexRoute {
|
|
39
|
+
GET: () => Promise<Response>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createSitemapIndex(opts: CreateSitemapIndexOptions): SitemapIndexRoute {
|
|
43
|
+
const {
|
|
44
|
+
host,
|
|
45
|
+
apiUrl,
|
|
46
|
+
indexRevalidate = DEFAULT_INDEX_REVALIDATE,
|
|
47
|
+
includeStatic = true,
|
|
48
|
+
} = opts;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async GET() {
|
|
52
|
+
const ids: string[] = includeStatic ? [STATIC_ID] : [];
|
|
53
|
+
|
|
54
|
+
if (apiUrl) {
|
|
55
|
+
const index = await fetchSitemapIndex(apiUrl, indexRevalidate);
|
|
56
|
+
for (const s of index.sources) {
|
|
57
|
+
for (const c of s.chunks) {
|
|
58
|
+
ids.push(encodeChunkId(s.name, c.cursor_to));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lastmod = new Date().toISOString();
|
|
64
|
+
const body = renderIndex(host, ids, lastmod);
|
|
65
|
+
|
|
66
|
+
return new Response(body, {
|
|
67
|
+
status: 200,
|
|
68
|
+
headers: {
|
|
69
|
+
'content-type': 'application/xml; charset=utf-8',
|
|
70
|
+
// Mirror the per-chunk revalidate so a cached CDN edge expires
|
|
71
|
+
// at the same cadence as the index it points at.
|
|
72
|
+
'cache-control': `public, s-maxage=${indexRevalidate}, stale-while-revalidate=${indexRevalidate}`,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderIndex(host: string, ids: string[], lastmod: string): string {
|
|
80
|
+
const lines: string[] = [];
|
|
81
|
+
lines.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
82
|
+
lines.push('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
|
|
83
|
+
for (const id of ids) {
|
|
84
|
+
lines.push(' <sitemap>');
|
|
85
|
+
lines.push(` <loc>${escapeXml(`${host}/sitemap/${id}.xml`)}</loc>`);
|
|
86
|
+
lines.push(` <lastmod>${lastmod}</lastmod>`);
|
|
87
|
+
lines.push(' </sitemap>');
|
|
88
|
+
}
|
|
89
|
+
lines.push('</sitemapindex>');
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function escapeXml(s: string): string {
|
|
94
|
+
return s
|
|
95
|
+
.replace(/&/g, '&')
|
|
96
|
+
.replace(/</g, '<')
|
|
97
|
+
.replace(/>/g, '>')
|
|
98
|
+
.replace(/"/g, '"')
|
|
99
|
+
.replace(/'/g, ''');
|
|
100
|
+
}
|
package/src/sitemap/index.ts
CHANGED
|
@@ -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';
|
package/src/sitemap/sitemap.ts
CHANGED
|
@@ -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>;
|
package/src/sitemap/types.ts
CHANGED
|
@@ -70,6 +70,24 @@ 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/']. */
|