@djangocfg/nextjs 2.1.411 → 2.1.413
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/README.md +30 -36
- package/dist/config/index.mjs +1 -1
- package/dist/config/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +110 -137
- package/dist/index.mjs.map +1 -1
- package/dist/sitemap/index.d.mts +126 -56
- package/dist/sitemap/index.mjs +107 -134
- package/dist/sitemap/index.mjs.map +1 -1
- package/package.json +9 -9
- package/src/sitemap/README.md +315 -0
- package/src/sitemap/fetch.ts +66 -0
- package/src/sitemap/ids.ts +21 -0
- package/src/sitemap/index.ts +22 -4
- package/src/sitemap/robots.ts +34 -0
- package/src/sitemap/sitemap.ts +94 -0
- package/src/sitemap/types.ts +67 -17
- package/src/sitemap/generator.ts +0 -144
- package/src/sitemap/route.ts +0 -146
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# @djangocfg/nextjs/sitemap
|
|
2
|
+
|
|
3
|
+
Frontend half of the djangocfg ↔ Next.js sitemap integration.
|
|
4
|
+
|
|
5
|
+
The Django-side backend (`django_cfg.modules.django_sitemap`) exposes a
|
|
6
|
+
paginated JSON feed. This subpackage turns that feed into a Google-compliant
|
|
7
|
+
XML sitemap-index via Next.js's native `generateSitemaps()` API.
|
|
8
|
+
|
|
9
|
+
## Why this exists
|
|
10
|
+
|
|
11
|
+
Two naive approaches fail at scale:
|
|
12
|
+
|
|
13
|
+
1. **Django serves XML directly** — wrong host (api.example.com vs
|
|
14
|
+
www.example.com), can't include frontend-only routes, hard to add
|
|
15
|
+
per-locale alternates, awkward sitemap-index dance.
|
|
16
|
+
2. **Frontend queries the DB** — frontends don't have DB access; falling
|
|
17
|
+
back to public REST endpoints means N round trips and no streaming.
|
|
18
|
+
|
|
19
|
+
This package solves both. Django returns path-only JSON; the frontend
|
|
20
|
+
joins the host and emits sitemap-index XML. Same backend can serve
|
|
21
|
+
multiple frontends (preview, staging, EU domain) — they each bring their
|
|
22
|
+
own host.
|
|
23
|
+
|
|
24
|
+
## The big picture
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
28
|
+
│ Django app (e.g. apps/catalog) │
|
|
29
|
+
│ sitemap_sources.py │
|
|
30
|
+
│ register(SitemapSource( │
|
|
31
|
+
│ name="vehicles", │
|
|
32
|
+
│ queryset_factory=lambda: Vehicle.objects.sitemap_eligible(), │
|
|
33
|
+
│ url_template="/catalog/{id}", │
|
|
34
|
+
│ lastmod_field="last_seen_at", │
|
|
35
|
+
│ cursor_fields=("last_seen_at", "id"), │
|
|
36
|
+
│ order="-last_seen_at,-id", │
|
|
37
|
+
│ page_size=50_000, │
|
|
38
|
+
│ )) │
|
|
39
|
+
└──────────────────────────┬───────────────────────────────────────────┘
|
|
40
|
+
│ AppConfig.ready() → in-process registry
|
|
41
|
+
▼
|
|
42
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
43
|
+
│ django_cfg.modules.django_sitemap (HTTP layer) │
|
|
44
|
+
│ │
|
|
45
|
+
│ GET /cfg/sitemap/index/ │
|
|
46
|
+
│ → list of chunks per source, with opaque keyset cursors │
|
|
47
|
+
│ │
|
|
48
|
+
│ GET /cfg/sitemap/feed/?source=<name>&cursor=<opaque> │
|
|
49
|
+
│ → 50 000 entries: { loc, lastmod } │
|
|
50
|
+
│ │
|
|
51
|
+
│ Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 │
|
|
52
|
+
└──────────────────────────┬───────────────────────────────────────────┘
|
|
53
|
+
│ HTTP (server-side fetch from Next)
|
|
54
|
+
▼
|
|
55
|
+
┌──────────────────────────────────────────────────────────────────────┐
|
|
56
|
+
│ Next.js app — apps/<app>/app/ │
|
|
57
|
+
│ │
|
|
58
|
+
│ sitemap.ts │
|
|
59
|
+
│ createDjangoSitemap({ host, apiUrl, staticRoutes }) │
|
|
60
|
+
│ ├ generateSitemaps() — reads /cfg/sitemap/index/, returns ids │
|
|
61
|
+
│ └ default sitemap() — fetches one /cfg/sitemap/feed/ per id │
|
|
62
|
+
│ │
|
|
63
|
+
│ robots.ts │
|
|
64
|
+
│ createRobots({ host, disallow }) │
|
|
65
|
+
└──────────────────────────────────────────────────────────────────────┘
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Next emits:
|
|
69
|
+
|
|
70
|
+
- `/sitemap.xml` — auto-generated `<sitemapindex>` listing every chunk.
|
|
71
|
+
- `/sitemap/<id>.xml` — one `<urlset>` per chunk (50k URLs max, per
|
|
72
|
+
Google's cap).
|
|
73
|
+
- `/robots.txt` — points crawlers at `/sitemap.xml`.
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
### 1. Register sources in Django
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
# apps/<your-app>/sitemap_sources.py
|
|
81
|
+
from django_cfg.modules.django_sitemap import SitemapSource, register
|
|
82
|
+
from apps.catalog.models import Vehicle, Brand
|
|
83
|
+
|
|
84
|
+
register(SitemapSource(
|
|
85
|
+
name="vehicles",
|
|
86
|
+
url_template="/catalog/{id}",
|
|
87
|
+
queryset_factory=lambda: Vehicle.objects.get_queryset().sitemap_eligible(),
|
|
88
|
+
fields={"id": "id"},
|
|
89
|
+
lastmod_field="last_seen_at",
|
|
90
|
+
cursor_fields=("last_seen_at", "id"),
|
|
91
|
+
order="-last_seen_at,-id",
|
|
92
|
+
page_size=50_000,
|
|
93
|
+
))
|
|
94
|
+
|
|
95
|
+
register(SitemapSource(
|
|
96
|
+
name="brands",
|
|
97
|
+
url_template="/catalog?brand={slug}",
|
|
98
|
+
queryset_factory=lambda: Brand.objects.filter(is_active=True),
|
|
99
|
+
fields={"slug": "slug"},
|
|
100
|
+
lastmod_field="updated_at",
|
|
101
|
+
cursor_fields=("slug",),
|
|
102
|
+
order="slug",
|
|
103
|
+
))
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`SitemapAppConfig.ready()` autoloads every installed app's
|
|
107
|
+
`sitemap_sources.py` — no host-side wiring needed. Verify with
|
|
108
|
+
`python manage.py sitemap_inspect`.
|
|
109
|
+
|
|
110
|
+
### 2. Wire up the frontend
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
// apps/<your-app>/app/sitemap.ts
|
|
114
|
+
import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';
|
|
115
|
+
|
|
116
|
+
const { generateSitemaps, sitemap } = createDjangoSitemap({
|
|
117
|
+
host: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
118
|
+
apiUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
119
|
+
staticRoutes: [
|
|
120
|
+
{ path: '/', changeFrequency: 'daily', priority: 1.0 },
|
|
121
|
+
{ path: '/catalog', changeFrequency: 'daily', priority: 0.9 },
|
|
122
|
+
{ path: '/ai-search', changeFrequency: 'weekly', priority: 0.7 },
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export { generateSitemaps, sitemap as default };
|
|
127
|
+
export const revalidate = 3600;
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
// apps/<your-app>/app/robots.ts
|
|
132
|
+
import { createRobots } from '@djangocfg/nextjs/sitemap';
|
|
133
|
+
|
|
134
|
+
export default createRobots({
|
|
135
|
+
host: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
136
|
+
disallow: ['/account/', '/auth', '/api/', '/apix/'],
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
That's the whole app-side integration.
|
|
141
|
+
|
|
142
|
+
## Backend without backend
|
|
143
|
+
|
|
144
|
+
Marketing sites and documentation portals usually don't have a Django
|
|
145
|
+
catalog to paginate. Omit `apiUrl` — the frontend then emits a
|
|
146
|
+
single-chunk sitemap from `staticRoutes` only.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
const { generateSitemaps, sitemap } = createDjangoSitemap({
|
|
150
|
+
host: 'https://example.com',
|
|
151
|
+
staticRoutes: [
|
|
152
|
+
{ path: '/', priority: 1.0 },
|
|
153
|
+
{ path: '/about', priority: 0.8 },
|
|
154
|
+
{ path: '/pricing', priority: 0.9 },
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## What gets emitted
|
|
160
|
+
|
|
161
|
+
For a 1 200 000-vehicle catalog + 6 static pages:
|
|
162
|
+
|
|
163
|
+
- `/sitemap.xml` — index with ~25 child entries (24 vehicle chunks +
|
|
164
|
+
brands + models + static).
|
|
165
|
+
- `/sitemap/static.xml` — 6 static URLs with `<changefreq>`/`<priority>`.
|
|
166
|
+
- `/sitemap/vehicles--<cursor>.xml` × 24 — 50 000 vehicle URLs each
|
|
167
|
+
(sorted by `last_seen_at DESC` so the freshest listings live in chunk 1
|
|
168
|
+
and Googlebot re-crawls them first).
|
|
169
|
+
- `/sitemap/brands--.xml` — one chunk.
|
|
170
|
+
- `/sitemap/models--.xml` — one chunk.
|
|
171
|
+
|
|
172
|
+
**Only `loc` and `lastmod`** are emitted from backend entries: Google
|
|
173
|
+
ignores `<changefreq>` and `<priority>` on dynamic URLs, and omitting
|
|
174
|
+
them keeps the payload small. Static routes still emit them — non-Google
|
|
175
|
+
crawlers (Bing, Yandex) read those fields, and they're free.
|
|
176
|
+
|
|
177
|
+
## Cache layers
|
|
178
|
+
|
|
179
|
+
Three layers cooperate; each TTL aligned with how often the data
|
|
180
|
+
*actually* changes.
|
|
181
|
+
|
|
182
|
+
| Layer | Default TTL | Tuned via |
|
|
183
|
+
|---|---|---|
|
|
184
|
+
| Django Redis (sitemap-index JSON) | 5 min | `SitemapConfig.cache_index_seconds` |
|
|
185
|
+
| Django Redis (per-chunk JSON) | 1 h | `SitemapConfig.cache_feed_seconds` |
|
|
186
|
+
| Next.js ISR (XML) | 1 h | `createDjangoSitemap({ feedRevalidate })` + `export const revalidate = ...` |
|
|
187
|
+
| HTTP `Cache-Control` | `s-maxage=3600, swr=86400` | Django sets, CDN respects |
|
|
188
|
+
|
|
189
|
+
Result: Googlebot fetching `/sitemap.xml` rarely touches Django — most
|
|
190
|
+
hits land on the CDN edge or Next ISR. Django only does real work once
|
|
191
|
+
per TTL.
|
|
192
|
+
|
|
193
|
+
To force a cache bust without `FLUSHDB`, bump `SitemapConfig.cache_version`
|
|
194
|
+
on the Django side; every Redis key includes it.
|
|
195
|
+
|
|
196
|
+
## Failure mode
|
|
197
|
+
|
|
198
|
+
`fetchSitemapIndex` and `fetchSitemapFeed` **never throw**. A transient
|
|
199
|
+
Django outage during `next build` is downgraded to:
|
|
200
|
+
|
|
201
|
+
- empty index → only the static chunk is emitted
|
|
202
|
+
- empty feed → that chunk renders an empty `<urlset/>`
|
|
203
|
+
|
|
204
|
+
`next build` completes, the bad cache entry expires on the next ISR
|
|
205
|
+
cycle, and the sitemap heals automatically. No build-time coupling to
|
|
206
|
+
Django uptime.
|
|
207
|
+
|
|
208
|
+
## Deployment
|
|
209
|
+
|
|
210
|
+
### Env vars
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
NEXT_PUBLIC_SITE_URL=https://example.com # canonical host
|
|
214
|
+
NEXT_PUBLIC_API_URL=https://api.example.com # Django backend
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Both are public — sitemap.ts runs on the server but the same `host` is
|
|
218
|
+
also embedded in client-side metadata.
|
|
219
|
+
|
|
220
|
+
### Docker
|
|
221
|
+
|
|
222
|
+
Nothing sitemap-specific is required in the container — the package is a
|
|
223
|
+
plain TypeScript dependency, no native modules, no extra ports. Just pass
|
|
224
|
+
the two env vars above via `env_file` or `environment:`.
|
|
225
|
+
|
|
226
|
+
The Next service's existing healthcheck on `/` is enough; no separate
|
|
227
|
+
sitemap probe needed (a missing sitemap doesn't break the app).
|
|
228
|
+
|
|
229
|
+
### CDN / Cloudflare
|
|
230
|
+
|
|
231
|
+
Cache by URL, respect origin `Cache-Control`:
|
|
232
|
+
|
|
233
|
+
| URL pattern | TTL fresh | TTL stale |
|
|
234
|
+
|---|---|---|
|
|
235
|
+
| `/sitemap.xml` | 1 h | 1 day |
|
|
236
|
+
| `/sitemap/*.xml` | 1 h | 1 day |
|
|
237
|
+
| `/robots.txt` | 24 h | 1 day |
|
|
238
|
+
|
|
239
|
+
Stale-while-revalidate keeps Googlebot fast even during a Django outage.
|
|
240
|
+
|
|
241
|
+
### Sitemap submission
|
|
242
|
+
|
|
243
|
+
Submit `https://<host>/sitemap.xml` once via Google Search Console.
|
|
244
|
+
Google polls it on its own schedule afterwards — no `ping` integration
|
|
245
|
+
needed.
|
|
246
|
+
|
|
247
|
+
## API reference
|
|
248
|
+
|
|
249
|
+
### `createDjangoSitemap(options)`
|
|
250
|
+
|
|
251
|
+
Returns `{ generateSitemaps, sitemap }`. Export them as Next.js
|
|
252
|
+
expects: named `generateSitemaps` + default-exported `sitemap`.
|
|
253
|
+
|
|
254
|
+
**Options:**
|
|
255
|
+
|
|
256
|
+
| Field | Type | Default | Notes |
|
|
257
|
+
|---|---|---|---|
|
|
258
|
+
| `host` | `string` | (required) | Canonical host (e.g. `https://example.com`). URLs are joined as `${host}${loc}`. |
|
|
259
|
+
| `apiUrl` | `string` | `undefined` | Django backend base URL. Omit to skip the backend feed entirely. |
|
|
260
|
+
| `staticRoutes` | `StaticRoute[]` | `[]` | Frontend-only routes. Auth/account routes should NOT be listed here. |
|
|
261
|
+
| `indexRevalidate` | `number` | `600` | Seconds — `next: { revalidate }` on the index fetch. |
|
|
262
|
+
| `feedRevalidate` | `number` | `3600` | Seconds — `next: { revalidate }` on each chunk fetch. |
|
|
263
|
+
|
|
264
|
+
**`StaticRoute`:**
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
interface StaticRoute {
|
|
268
|
+
path: string;
|
|
269
|
+
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
270
|
+
priority?: number; // 0.0 – 1.0
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### `createRobots(options)`
|
|
275
|
+
|
|
276
|
+
Returns a Next.js `() => MetadataRoute.Robots` function. Default-export
|
|
277
|
+
it from `app/robots.ts`.
|
|
278
|
+
|
|
279
|
+
**Options:**
|
|
280
|
+
|
|
281
|
+
| Field | Type | Default | Notes |
|
|
282
|
+
|---|---|---|---|
|
|
283
|
+
| `host` | `string` | (required) | Canonical host. |
|
|
284
|
+
| `disallow` | `string[]` | `['/account/', '/auth', '/api/']` | Path prefixes to block. |
|
|
285
|
+
| `sitemap` | `string` | `${host}/sitemap.xml` | Override the sitemap pointer. |
|
|
286
|
+
|
|
287
|
+
### Lower-level utilities
|
|
288
|
+
|
|
289
|
+
Exposed for advanced use cases (custom Next route handlers, debugging):
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
import {
|
|
293
|
+
fetchSitemapIndex,
|
|
294
|
+
fetchSitemapFeed,
|
|
295
|
+
encodeChunkId,
|
|
296
|
+
decodeChunkId,
|
|
297
|
+
} from '@djangocfg/nextjs/sitemap';
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## When NOT to use this
|
|
301
|
+
|
|
302
|
+
- Sites under ~1 000 URLs with no backend pagination needs — a flat
|
|
303
|
+
`staticRoutes`-only configuration is fine, but a hand-written
|
|
304
|
+
`sitemap.ts` returning a literal array works just as well.
|
|
305
|
+
- You need per-locale `<xhtml:link rel="alternate">` tags — not currently
|
|
306
|
+
emitted; the wire contract has room for it (`alternates`) but no
|
|
307
|
+
consumer requires it yet.
|
|
308
|
+
- You need image/video/news sitemap extensions — not implemented.
|
|
309
|
+
|
|
310
|
+
## See also
|
|
311
|
+
|
|
312
|
+
- `django_cfg.modules.django_sitemap` — backend module (registry, HTTP
|
|
313
|
+
views, `sitemap_inspect` management command).
|
|
314
|
+
- [Next.js docs — generateSitemaps](https://nextjs.org/docs/app/api-reference/functions/generate-sitemaps)
|
|
315
|
+
- [Google — large sitemaps](https://developers.google.com/search/docs/crawling-indexing/sitemaps/large-sitemaps)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap fetch helpers — never throw.
|
|
3
|
+
*
|
|
4
|
+
* Failures are converted to empty payloads so a transient Django outage
|
|
5
|
+
* doesn't break `next build`. The next ISR cycle (`revalidate`) recovers
|
|
6
|
+
* automatically.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SitemapFeedPage, SitemapIndex } from './types';
|
|
10
|
+
|
|
11
|
+
const EMPTY_INDEX: SitemapIndex = {
|
|
12
|
+
sources: [],
|
|
13
|
+
generated_at: new Date(0).toISOString(),
|
|
14
|
+
ttl_seconds: 60,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const emptyFeed = (source: string, cursor: string | null): SitemapFeedPage => ({
|
|
18
|
+
source,
|
|
19
|
+
chunk_id: `${source}-empty`,
|
|
20
|
+
count: 0,
|
|
21
|
+
has_more: false,
|
|
22
|
+
next_cursor: cursor,
|
|
23
|
+
entries: [],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export async function fetchSitemapIndex(
|
|
27
|
+
apiUrl: string,
|
|
28
|
+
revalidate: number,
|
|
29
|
+
): Promise<SitemapIndex> {
|
|
30
|
+
try {
|
|
31
|
+
const r = await fetch(`${apiUrl}/cfg/sitemap/index/`, {
|
|
32
|
+
next: { revalidate },
|
|
33
|
+
});
|
|
34
|
+
if (!r.ok) {
|
|
35
|
+
console.warn(`[sitemap] index fetch returned ${r.status}; falling back to empty`);
|
|
36
|
+
return EMPTY_INDEX;
|
|
37
|
+
}
|
|
38
|
+
return (await r.json()) as SitemapIndex;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.warn('[sitemap] index fetch failed:', err);
|
|
41
|
+
return EMPTY_INDEX;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function fetchSitemapFeed(
|
|
46
|
+
apiUrl: string,
|
|
47
|
+
source: string,
|
|
48
|
+
cursor: string | null,
|
|
49
|
+
revalidate: number,
|
|
50
|
+
): Promise<SitemapFeedPage> {
|
|
51
|
+
const params = new URLSearchParams({ source });
|
|
52
|
+
if (cursor) params.set('cursor', cursor);
|
|
53
|
+
try {
|
|
54
|
+
const r = await fetch(`${apiUrl}/cfg/sitemap/feed/?${params}`, {
|
|
55
|
+
next: { revalidate },
|
|
56
|
+
});
|
|
57
|
+
if (!r.ok) {
|
|
58
|
+
console.warn(`[sitemap] feed ${source} returned ${r.status}; falling back to empty`);
|
|
59
|
+
return emptyFeed(source, cursor);
|
|
60
|
+
}
|
|
61
|
+
return (await r.json()) as SitemapFeedPage;
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.warn(`[sitemap] feed ${source} fetch failed:`, err);
|
|
64
|
+
return emptyFeed(source, cursor);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap chunk id encoding.
|
|
3
|
+
*
|
|
4
|
+
* Each Next.js sitemap file is addressed by an opaque string id. We
|
|
5
|
+
* round-trip a `(source, cursor)` pair through that id so the default
|
|
6
|
+
* sitemap handler knows which Django chunk to fetch.
|
|
7
|
+
*
|
|
8
|
+
* Separator `--` is unambiguous: backend cursors use URL-safe base64 (`-_`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export function encodeChunkId(source: string, cursor: string | null): string {
|
|
12
|
+
return `${source}--${cursor ?? ''}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function decodeChunkId(id: string): { source: string; cursor: string | null } {
|
|
16
|
+
const sep = id.indexOf('--');
|
|
17
|
+
if (sep === -1) return { source: id, cursor: null };
|
|
18
|
+
const source = id.slice(0, sep);
|
|
19
|
+
const cursorRaw = id.slice(sep + 2);
|
|
20
|
+
return { source, cursor: cursorRaw === '' ? null : cursorRaw };
|
|
21
|
+
}
|
package/src/sitemap/index.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* @djangocfg/nextjs/sitemap
|
|
3
|
+
*
|
|
4
|
+
* Sitemap-index + per-chunk Next.js sitemap factory backed by
|
|
5
|
+
* `django_cfg.modules.django_sitemap`. Handles catalogs up to Google's
|
|
6
|
+
* per-file URL caps without materialising data in the frontend.
|
|
7
|
+
*
|
|
8
|
+
* See `createDjangoSitemap` for the main entry point and `createRobots`
|
|
9
|
+
* for the matching robots.txt factory.
|
|
3
10
|
*/
|
|
4
11
|
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export
|
|
12
|
+
export { createDjangoSitemap } from './sitemap';
|
|
13
|
+
export { createRobots } from './robots';
|
|
14
|
+
export { encodeChunkId, decodeChunkId } from './ids';
|
|
15
|
+
export { fetchSitemapIndex, fetchSitemapFeed } from './fetch';
|
|
8
16
|
|
|
17
|
+
export type {
|
|
18
|
+
SitemapChunkInfo,
|
|
19
|
+
SitemapEntry,
|
|
20
|
+
SitemapFeedPage,
|
|
21
|
+
SitemapIndex,
|
|
22
|
+
SitemapSourceInfo,
|
|
23
|
+
CreateDjangoSitemapOptions,
|
|
24
|
+
CreateRobotsOptions,
|
|
25
|
+
StaticRoute,
|
|
26
|
+
} from './types';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createRobots — factory for Next.js `app/robots.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Emits a single `User-Agent: *` block plus a Sitemap pointer. Disallow
|
|
5
|
+
* defaults cover the common cases (auth, account, API surface) — override
|
|
6
|
+
* per app if needed.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // apps/<app>/app/robots.ts
|
|
12
|
+
* import { createRobots } from '@djangocfg/nextjs/sitemap';
|
|
13
|
+
*
|
|
14
|
+
* export default createRobots({
|
|
15
|
+
* host: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
16
|
+
* disallow: ['/account/', '/auth', '/api/', '/apix/'],
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { MetadataRoute } from 'next';
|
|
22
|
+
|
|
23
|
+
import type { CreateRobotsOptions } from './types';
|
|
24
|
+
|
|
25
|
+
const DEFAULT_DISALLOW = ['/account/', '/auth', '/api/'];
|
|
26
|
+
|
|
27
|
+
export function createRobots(opts: CreateRobotsOptions): () => MetadataRoute.Robots {
|
|
28
|
+
const { host, disallow = DEFAULT_DISALLOW, sitemap } = opts;
|
|
29
|
+
return () => ({
|
|
30
|
+
rules: [{ userAgent: '*', allow: '/', disallow }],
|
|
31
|
+
sitemap: sitemap ?? `${host}/sitemap.xml`,
|
|
32
|
+
host,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createDjangoSitemap — factory for Next.js `app/sitemap.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Backed by `django_cfg.modules.django_sitemap`: the backend exposes a
|
|
5
|
+
* paginated JSON feed, this factory turns each chunk into a Next.js
|
|
6
|
+
* sitemap file via `generateSitemaps()`.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* // apps/<app>/app/sitemap.ts
|
|
12
|
+
* import { createDjangoSitemap } from '@djangocfg/nextjs/sitemap';
|
|
13
|
+
*
|
|
14
|
+
* const { generateSitemaps, sitemap } = createDjangoSitemap({
|
|
15
|
+
* host: process.env.NEXT_PUBLIC_SITE_URL!,
|
|
16
|
+
* apiUrl: process.env.NEXT_PUBLIC_API_URL!,
|
|
17
|
+
* staticRoutes: [
|
|
18
|
+
* { path: '/', changeFrequency: 'daily', priority: 1.0 },
|
|
19
|
+
* { path: '/catalog', changeFrequency: 'daily', priority: 0.9 },
|
|
20
|
+
* ],
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* export { generateSitemaps, sitemap as default };
|
|
24
|
+
* export const revalidate = 3600;
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Without `apiUrl`, the sitemap emits only the static routes — handy for
|
|
28
|
+
* marketing sites that don't have a backend-driven URL space.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { MetadataRoute } from 'next';
|
|
32
|
+
|
|
33
|
+
import { fetchSitemapFeed, fetchSitemapIndex } from './fetch';
|
|
34
|
+
import { decodeChunkId, encodeChunkId } from './ids';
|
|
35
|
+
|
|
36
|
+
import type { CreateDjangoSitemapOptions, StaticRoute } from './types';
|
|
37
|
+
|
|
38
|
+
const STATIC_ID = 'static';
|
|
39
|
+
const DEFAULT_INDEX_REVALIDATE = 600;
|
|
40
|
+
const DEFAULT_FEED_REVALIDATE = 3600;
|
|
41
|
+
|
|
42
|
+
interface SitemapApi {
|
|
43
|
+
generateSitemaps: () => Promise<Array<{ id: string }>>;
|
|
44
|
+
sitemap: (props: { id: Promise<string> }) => Promise<MetadataRoute.Sitemap>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createDjangoSitemap(opts: CreateDjangoSitemapOptions): SitemapApi {
|
|
48
|
+
const {
|
|
49
|
+
host,
|
|
50
|
+
apiUrl,
|
|
51
|
+
staticRoutes = [],
|
|
52
|
+
indexRevalidate = DEFAULT_INDEX_REVALIDATE,
|
|
53
|
+
feedRevalidate = DEFAULT_FEED_REVALIDATE,
|
|
54
|
+
} = opts;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
async generateSitemaps() {
|
|
58
|
+
const ids: Array<{ id: string }> = [{ id: STATIC_ID }];
|
|
59
|
+
if (!apiUrl) return ids;
|
|
60
|
+
const index = await fetchSitemapIndex(apiUrl, indexRevalidate);
|
|
61
|
+
for (const s of index.sources) {
|
|
62
|
+
for (const c of s.chunks) {
|
|
63
|
+
ids.push({ id: encodeChunkId(s.name, c.cursor_to) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async sitemap({ id: idPromise }) {
|
|
70
|
+
const id = await idPromise;
|
|
71
|
+
|
|
72
|
+
if (id === STATIC_ID) {
|
|
73
|
+
return renderStatic(host, staticRoutes);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!apiUrl) return [];
|
|
77
|
+
|
|
78
|
+
const { source, cursor } = decodeChunkId(id);
|
|
79
|
+
const feed = await fetchSitemapFeed(apiUrl, source, cursor, feedRevalidate);
|
|
80
|
+
return feed.entries.map((e) => ({
|
|
81
|
+
url: `${host}${e.loc}`,
|
|
82
|
+
lastModified: e.lastmod ? new Date(e.lastmod) : undefined,
|
|
83
|
+
}));
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderStatic(host: string, routes: StaticRoute[]): MetadataRoute.Sitemap {
|
|
89
|
+
return routes.map((r) => ({
|
|
90
|
+
url: `${host}${r.path}`,
|
|
91
|
+
changeFrequency: r.changeFrequency,
|
|
92
|
+
priority: r.priority,
|
|
93
|
+
}));
|
|
94
|
+
}
|
package/src/sitemap/types.ts
CHANGED
|
@@ -1,29 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sitemap types
|
|
2
|
+
* Sitemap module — types
|
|
3
|
+
*
|
|
4
|
+
* Wire-contract for `django_cfg.modules.django_sitemap` JSON endpoints
|
|
5
|
+
* plus the public option types for `createDjangoSitemap` and
|
|
6
|
+
* `createRobots`.
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
|
-
import type { ChangeFreq
|
|
9
|
+
import type { ChangeFreq } from '../types';
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
// ── Wire contract with Django backend ───────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface SitemapChunkInfo {
|
|
14
|
+
id: string;
|
|
15
|
+
cursor_to: string | null;
|
|
16
|
+
count_estimate: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SitemapSourceInfo {
|
|
20
|
+
name: string;
|
|
21
|
+
chunks: SitemapChunkInfo[];
|
|
22
|
+
total_estimate: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SitemapIndex {
|
|
26
|
+
sources: SitemapSourceInfo[];
|
|
27
|
+
generated_at: string;
|
|
28
|
+
ttl_seconds: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SitemapEntry {
|
|
32
|
+
loc: string;
|
|
33
|
+
lastmod?: string | null;
|
|
12
34
|
}
|
|
13
35
|
|
|
14
|
-
export interface
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
36
|
+
export interface SitemapFeedPage {
|
|
37
|
+
source: string;
|
|
38
|
+
chunk_id: string;
|
|
39
|
+
count: number;
|
|
40
|
+
has_more: boolean;
|
|
41
|
+
next_cursor: string | null;
|
|
42
|
+
entries: SitemapEntry[];
|
|
21
43
|
}
|
|
22
44
|
|
|
23
|
-
|
|
45
|
+
// ── Public option types ─────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface StaticRoute {
|
|
24
48
|
path: string;
|
|
25
|
-
|
|
26
|
-
changefreq?: ChangeFreq;
|
|
49
|
+
changeFrequency?: ChangeFreq;
|
|
27
50
|
priority?: number;
|
|
28
51
|
}
|
|
29
52
|
|
|
53
|
+
export interface CreateDjangoSitemapOptions {
|
|
54
|
+
/** Canonical site host, e.g. `https://vamcar.com`. URLs in the sitemap
|
|
55
|
+
* are joined as `${host}${loc}`. */
|
|
56
|
+
host: string;
|
|
57
|
+
|
|
58
|
+
/** Django backend base URL. When omitted, backend chunks are skipped
|
|
59
|
+
* and only `staticRoutes` are emitted (single-chunk sitemap). */
|
|
60
|
+
apiUrl?: string;
|
|
61
|
+
|
|
62
|
+
/** Frontend-only routes the backend doesn't know about. Auth/account
|
|
63
|
+
* pages should NOT be listed here. */
|
|
64
|
+
staticRoutes?: StaticRoute[];
|
|
65
|
+
|
|
66
|
+
/** Index revalidate window in seconds. Default: 600. */
|
|
67
|
+
indexRevalidate?: number;
|
|
68
|
+
|
|
69
|
+
/** Feed (chunk) revalidate window in seconds. Default: 3600. */
|
|
70
|
+
feedRevalidate?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CreateRobotsOptions {
|
|
74
|
+
host: string;
|
|
75
|
+
/** Disallow patterns. Default: ['/account/', '/auth', '/api/']. */
|
|
76
|
+
disallow?: string[];
|
|
77
|
+
/** Override sitemap URL. Default: `${host}/sitemap.xml`. */
|
|
78
|
+
sitemap?: string;
|
|
79
|
+
}
|