@onruntime/next-sitemap 0.2.1 → 0.4.0
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 +166 -3
- package/dist/app/index.cjs +41 -13
- package/dist/app/index.d.cts +6 -0
- package/dist/app/index.d.ts +6 -0
- package/dist/app/index.js +41 -13
- package/dist/pages/index.cjs +267 -0
- package/dist/pages/index.d.cts +114 -0
- package/dist/pages/index.d.ts +114 -0
- package/dist/pages/index.js +263 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -5,11 +5,15 @@ Dynamic sitemap generation for Next.js with automatic route discovery.
|
|
|
5
5
|
## Example
|
|
6
6
|
|
|
7
7
|
- [Next.js App Router](https://github.com/onRuntime/onruntime/tree/master/examples/next-sitemap/app)
|
|
8
|
+
- [Next.js App Router with i18n](https://github.com/onRuntime/onruntime/tree/master/examples/next-sitemap/app-with-locales)
|
|
9
|
+
- [Next.js Pages Router](https://github.com/onRuntime/onruntime/tree/master/examples/next-sitemap/pages)
|
|
10
|
+
- [Next.js Pages Router with i18n](https://github.com/onRuntime/onruntime/tree/master/examples/next-sitemap/pages-with-locales)
|
|
8
11
|
|
|
9
12
|
## Features
|
|
10
13
|
|
|
14
|
+
- **App Router** and **Pages Router** support
|
|
11
15
|
- Automatic route discovery using `require.context`
|
|
12
|
-
- Calls `generateStaticParams` for dynamic routes
|
|
16
|
+
- Calls `generateStaticParams` (App Router) or `getStaticPaths` (Pages Router) for dynamic routes
|
|
13
17
|
- Multi-sitemap support with sitemap index (for sites with >50,000 URLs)
|
|
14
18
|
- hreflang alternates for i18n
|
|
15
19
|
- Fully static generation (SSG)
|
|
@@ -104,6 +108,89 @@ const nextConfig: NextConfig = {
|
|
|
104
108
|
export default nextConfig;
|
|
105
109
|
```
|
|
106
110
|
|
|
111
|
+
### Next.js Pages Router
|
|
112
|
+
|
|
113
|
+
#### 1. Create the sitemap index API route
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
// pages/api/sitemap.xml.ts
|
|
117
|
+
import { createSitemapIndexApiHandler } from "@onruntime/next-sitemap/pages";
|
|
118
|
+
|
|
119
|
+
// @ts-expect-error - require.context is a webpack/turbopack feature
|
|
120
|
+
const pagesContext = require.context("../", true, /^\.\/(?!\[|_|api\/).*\.tsx$/);
|
|
121
|
+
|
|
122
|
+
export default createSitemapIndexApiHandler({
|
|
123
|
+
baseUrl: "https://example.com",
|
|
124
|
+
pagesContext,
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### 2. Create the individual sitemap API route
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// pages/api/sitemap/[id].ts
|
|
132
|
+
import { createSitemapApiHandler } from "@onruntime/next-sitemap/pages";
|
|
133
|
+
|
|
134
|
+
// @ts-expect-error - require.context is a webpack/turbopack feature
|
|
135
|
+
const pagesContext = require.context("../../", true, /^\.\/(?!\[|_|api\/).*\.tsx$/);
|
|
136
|
+
|
|
137
|
+
export default createSitemapApiHandler({
|
|
138
|
+
baseUrl: "https://example.com",
|
|
139
|
+
pagesContext,
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
#### 3. Add URL rewrite in next.config.ts
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// next.config.ts
|
|
147
|
+
import type { NextConfig } from "next";
|
|
148
|
+
|
|
149
|
+
const nextConfig: NextConfig = {
|
|
150
|
+
async rewrites() {
|
|
151
|
+
return [
|
|
152
|
+
{
|
|
153
|
+
source: "/sitemap.xml",
|
|
154
|
+
destination: "/api/sitemap.xml",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
source: "/sitemap-:id.xml",
|
|
158
|
+
destination: "/api/sitemap/:id",
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export default nextConfig;
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
#### With i18n (optional)
|
|
168
|
+
|
|
169
|
+
The Pages Router uses Next.js native i18n config in `next.config.js`. Pages stay in `pages/` (no `[locale]` folder), and you just need to provide `locales` and `defaultLocale`:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// next.config.js
|
|
173
|
+
module.exports = {
|
|
174
|
+
i18n: {
|
|
175
|
+
locales: ['en', 'fr'],
|
|
176
|
+
defaultLocale: 'en',
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// pages/api/sitemap.xml.ts
|
|
181
|
+
import { createSitemapIndexApiHandler } from "@onruntime/next-sitemap/pages";
|
|
182
|
+
|
|
183
|
+
// @ts-expect-error - require.context is a webpack/turbopack feature
|
|
184
|
+
const pagesContext = require.context("../", true, /^\.\/(?!\[|_|api\/).*\.tsx$/);
|
|
185
|
+
|
|
186
|
+
export default createSitemapIndexApiHandler({
|
|
187
|
+
baseUrl: "https://example.com",
|
|
188
|
+
locales: ["en", "fr"],
|
|
189
|
+
defaultLocale: "en",
|
|
190
|
+
pagesContext,
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
107
194
|
### Configuration Options
|
|
108
195
|
|
|
109
196
|
| Option | Type | Default | Description |
|
|
@@ -118,6 +205,7 @@ export default nextConfig;
|
|
|
118
205
|
| `priority` | `number`, `"auto"`, or `function` | `"auto"` | Priority calculation (auto = depth-based) |
|
|
119
206
|
| `changeFreq` | `ChangeFrequency` or `function` | `"weekly"` | Change frequency for entries |
|
|
120
207
|
| `additionalSitemaps` | `string[]` | `[]` | Additional sitemaps to include in index |
|
|
208
|
+
| `debug` | `boolean` | `false` | Enable debug logging to diagnose route discovery issues |
|
|
121
209
|
|
|
122
210
|
#### Exclude Routes
|
|
123
211
|
|
|
@@ -191,9 +279,9 @@ const { GET } = createSitemapIndexHandler({
|
|
|
191
279
|
|
|
192
280
|
### How It Works
|
|
193
281
|
|
|
194
|
-
1. `require.context` scans your app directory at build time
|
|
282
|
+
1. `require.context` scans your app/pages directory at build time
|
|
195
283
|
2. For each page found, it extracts the route path
|
|
196
|
-
3. For dynamic routes (e.g., `/projects/[id]`), it calls `generateStaticParams`
|
|
284
|
+
3. For dynamic routes (e.g., `/projects/[id]`), it calls `generateStaticParams` (App Router) or `getStaticPaths` (Pages Router)
|
|
197
285
|
4. URLs are paginated into multiple sitemaps (default: 5000 URLs each)
|
|
198
286
|
5. A sitemap index lists all individual sitemaps
|
|
199
287
|
|
|
@@ -266,6 +354,81 @@ export async function GET() {
|
|
|
266
354
|
</urlset>
|
|
267
355
|
```
|
|
268
356
|
|
|
357
|
+
## Troubleshooting
|
|
358
|
+
|
|
359
|
+
### Dynamic routes not included in sitemap
|
|
360
|
+
|
|
361
|
+
If your dynamic routes (e.g., `/articles/[slug]`) are not appearing in the sitemap, enable debug mode to diagnose:
|
|
362
|
+
|
|
363
|
+
```typescript
|
|
364
|
+
const { GET } = createSitemapIndexHandler({
|
|
365
|
+
baseUrl: "https://example.com",
|
|
366
|
+
pagesContext,
|
|
367
|
+
debug: true, // Enable debug logging
|
|
368
|
+
});
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
This will log:
|
|
372
|
+
- All discovered routes and whether `generateStaticParams` was found
|
|
373
|
+
- Number of params returned by each `generateStaticParams` call
|
|
374
|
+
- Any errors that occur during param generation
|
|
375
|
+
|
|
376
|
+
**Common issues:**
|
|
377
|
+
|
|
378
|
+
1. **`generateStaticParams`/`getStaticPaths` not detected**: Make sure it's exported at the top level of your page file:
|
|
379
|
+
```typescript
|
|
380
|
+
// ✅ Correct (App Router)
|
|
381
|
+
export async function generateStaticParams() { ... }
|
|
382
|
+
|
|
383
|
+
// ✅ Correct (Pages Router)
|
|
384
|
+
export async function getStaticPaths() { ... }
|
|
385
|
+
|
|
386
|
+
// ❌ Wrong - not exported
|
|
387
|
+
async function generateStaticParams() { ... }
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
2. **Database/API errors**: If `generateStaticParams` fetches data from a database or API, errors are caught and logged. Check the console for error messages.
|
|
391
|
+
|
|
392
|
+
3. **Empty params returned**: If `generateStaticParams` returns an empty array, no dynamic paths will be generated.
|
|
393
|
+
|
|
394
|
+
### Recommended approach for API/Database routes
|
|
395
|
+
|
|
396
|
+
For routes that fetch data from external sources (APIs, databases like Payload CMS), we recommend using `additionalSitemaps` with a custom sitemap route:
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
// app/sitemap.xml/route.ts
|
|
400
|
+
const { GET } = createSitemapIndexHandler({
|
|
401
|
+
baseUrl: "https://example.com",
|
|
402
|
+
pagesContext,
|
|
403
|
+
additionalSitemaps: ["/articles-sitemap.xml"],
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// app/articles-sitemap.xml/route.ts
|
|
407
|
+
import { generateSitemapXml } from "@onruntime/next-sitemap";
|
|
408
|
+
import { getPayload } from "payload";
|
|
409
|
+
|
|
410
|
+
export async function GET() {
|
|
411
|
+
const payload = await getPayload({ config: configPromise });
|
|
412
|
+
const articles = await payload.find({
|
|
413
|
+
collection: "articles",
|
|
414
|
+
limit: 1000,
|
|
415
|
+
select: { slug: true, updatedAt: true },
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const entries = articles.docs.map((article) => ({
|
|
419
|
+
url: `https://example.com/articles/${article.slug}`,
|
|
420
|
+
lastModified: article.updatedAt,
|
|
421
|
+
priority: 0.7,
|
|
422
|
+
}));
|
|
423
|
+
|
|
424
|
+
return new Response(generateSitemapXml(entries), {
|
|
425
|
+
headers: { "Content-Type": "application/xml" },
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
This approach gives you full control over data fetching and error handling.
|
|
431
|
+
|
|
269
432
|
## License
|
|
270
433
|
|
|
271
434
|
MIT
|
package/dist/app/index.cjs
CHANGED
|
@@ -123,7 +123,7 @@ function extractRoutes(pagesContext, localeSegment) {
|
|
|
123
123
|
}
|
|
124
124
|
return routes;
|
|
125
125
|
}
|
|
126
|
-
async function getAllPaths(routes) {
|
|
126
|
+
async function getAllPaths(routes, debug = false) {
|
|
127
127
|
const allPaths = ["/"];
|
|
128
128
|
const seenPaths = /* @__PURE__ */ new Set(["/"]);
|
|
129
129
|
for (const route of routes) {
|
|
@@ -133,16 +133,36 @@ async function getAllPaths(routes) {
|
|
|
133
133
|
seenPaths.add(route.pathname);
|
|
134
134
|
}
|
|
135
135
|
} else if (route.getParams) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
dynamicPath = dynamicPath.replace(`[${segment}]`, param[segment]);
|
|
136
|
+
try {
|
|
137
|
+
const params = await route.getParams();
|
|
138
|
+
if (debug) {
|
|
139
|
+
console.log(`[next-sitemap] ${route.pathname}: generateStaticParams returned ${params.length} params`);
|
|
141
140
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
for (const param of params) {
|
|
142
|
+
let dynamicPath = route.pathname;
|
|
143
|
+
for (const segment of route.dynamicSegments) {
|
|
144
|
+
const value = param[segment];
|
|
145
|
+
if (value === void 0) {
|
|
146
|
+
if (debug) {
|
|
147
|
+
console.warn(`[next-sitemap] ${route.pathname}: missing param "${segment}" in`, param);
|
|
148
|
+
}
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
dynamicPath = dynamicPath.replace(`[${segment}]`, value);
|
|
152
|
+
}
|
|
153
|
+
if (!seenPaths.has(dynamicPath)) {
|
|
154
|
+
allPaths.push(dynamicPath);
|
|
155
|
+
seenPaths.add(dynamicPath);
|
|
156
|
+
}
|
|
145
157
|
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.error(`[next-sitemap] Error calling generateStaticParams for ${route.pathname}:`, error);
|
|
160
|
+
}
|
|
161
|
+
} else if (route.dynamicSegments.length > 0) {
|
|
162
|
+
if (debug) {
|
|
163
|
+
console.warn(
|
|
164
|
+
`[next-sitemap] Skipping dynamic route ${route.pathname}: no generateStaticParams exported. Use additionalSitemaps for routes that fetch data at runtime.`
|
|
165
|
+
);
|
|
146
166
|
}
|
|
147
167
|
}
|
|
148
168
|
}
|
|
@@ -172,12 +192,20 @@ function pathsToEntries(paths, config) {
|
|
|
172
192
|
});
|
|
173
193
|
}
|
|
174
194
|
function createSitemapIndexHandler(options) {
|
|
175
|
-
const { urlsPerSitemap = 5e3, locales = [], defaultLocale, additionalSitemaps, exclude } = options;
|
|
195
|
+
const { urlsPerSitemap = 5e3, locales = [], defaultLocale, additionalSitemaps, exclude, debug = false } = options;
|
|
176
196
|
const localeSegment = options.localeSegment ?? (locales.length > 0 || defaultLocale ? "[locale]" : "");
|
|
177
197
|
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
198
|
+
if (debug) {
|
|
199
|
+
console.log(`[next-sitemap] Found ${routes.length} routes:`);
|
|
200
|
+
routes.forEach((r) => {
|
|
201
|
+
const hasParams = r.getParams ? "\u2713 generateStaticParams" : "\u2717 no generateStaticParams";
|
|
202
|
+
const segments = r.dynamicSegments.length > 0 ? ` [${r.dynamicSegments.join(", ")}]` : "";
|
|
203
|
+
console.log(` ${r.pathname}${segments} - ${hasParams}`);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
178
206
|
return {
|
|
179
207
|
GET: async () => {
|
|
180
|
-
const allPaths = await getAllPaths(routes);
|
|
208
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
181
209
|
const filteredPaths = allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
182
210
|
const sitemapCount = Math.max(1, Math.ceil(filteredPaths.length / urlsPerSitemap));
|
|
183
211
|
const xml = generateSitemapIndexXml(options.baseUrl, sitemapCount, {
|
|
@@ -190,11 +218,11 @@ function createSitemapIndexHandler(options) {
|
|
|
190
218
|
};
|
|
191
219
|
}
|
|
192
220
|
function createSitemapHandler(options) {
|
|
193
|
-
const { urlsPerSitemap = 5e3, locales = [], defaultLocale, exclude } = options;
|
|
221
|
+
const { urlsPerSitemap = 5e3, locales = [], defaultLocale, exclude, debug = false } = options;
|
|
194
222
|
const localeSegment = options.localeSegment ?? (locales.length > 0 || defaultLocale ? "[locale]" : "");
|
|
195
223
|
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
196
224
|
const getFilteredPaths = async () => {
|
|
197
|
-
const allPaths = await getAllPaths(routes);
|
|
225
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
198
226
|
return allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
199
227
|
};
|
|
200
228
|
return {
|
package/dist/app/index.d.cts
CHANGED
|
@@ -79,6 +79,12 @@ interface CreateSitemapHandlerOptions extends SitemapConfig {
|
|
|
79
79
|
keys: () => string[];
|
|
80
80
|
(key: string): PageModule;
|
|
81
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* Enable debug logging to diagnose issues with route discovery
|
|
84
|
+
* Logs info about generateStaticParams calls and skipped routes
|
|
85
|
+
* @default false
|
|
86
|
+
*/
|
|
87
|
+
debug?: boolean;
|
|
82
88
|
}
|
|
83
89
|
/**
|
|
84
90
|
* Create handlers for sitemap index route
|
package/dist/app/index.d.ts
CHANGED
|
@@ -79,6 +79,12 @@ interface CreateSitemapHandlerOptions extends SitemapConfig {
|
|
|
79
79
|
keys: () => string[];
|
|
80
80
|
(key: string): PageModule;
|
|
81
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* Enable debug logging to diagnose issues with route discovery
|
|
84
|
+
* Logs info about generateStaticParams calls and skipped routes
|
|
85
|
+
* @default false
|
|
86
|
+
*/
|
|
87
|
+
debug?: boolean;
|
|
82
88
|
}
|
|
83
89
|
/**
|
|
84
90
|
* Create handlers for sitemap index route
|
package/dist/app/index.js
CHANGED
|
@@ -121,7 +121,7 @@ function extractRoutes(pagesContext, localeSegment) {
|
|
|
121
121
|
}
|
|
122
122
|
return routes;
|
|
123
123
|
}
|
|
124
|
-
async function getAllPaths(routes) {
|
|
124
|
+
async function getAllPaths(routes, debug = false) {
|
|
125
125
|
const allPaths = ["/"];
|
|
126
126
|
const seenPaths = /* @__PURE__ */ new Set(["/"]);
|
|
127
127
|
for (const route of routes) {
|
|
@@ -131,16 +131,36 @@ async function getAllPaths(routes) {
|
|
|
131
131
|
seenPaths.add(route.pathname);
|
|
132
132
|
}
|
|
133
133
|
} else if (route.getParams) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
dynamicPath = dynamicPath.replace(`[${segment}]`, param[segment]);
|
|
134
|
+
try {
|
|
135
|
+
const params = await route.getParams();
|
|
136
|
+
if (debug) {
|
|
137
|
+
console.log(`[next-sitemap] ${route.pathname}: generateStaticParams returned ${params.length} params`);
|
|
139
138
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
139
|
+
for (const param of params) {
|
|
140
|
+
let dynamicPath = route.pathname;
|
|
141
|
+
for (const segment of route.dynamicSegments) {
|
|
142
|
+
const value = param[segment];
|
|
143
|
+
if (value === void 0) {
|
|
144
|
+
if (debug) {
|
|
145
|
+
console.warn(`[next-sitemap] ${route.pathname}: missing param "${segment}" in`, param);
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
dynamicPath = dynamicPath.replace(`[${segment}]`, value);
|
|
150
|
+
}
|
|
151
|
+
if (!seenPaths.has(dynamicPath)) {
|
|
152
|
+
allPaths.push(dynamicPath);
|
|
153
|
+
seenPaths.add(dynamicPath);
|
|
154
|
+
}
|
|
143
155
|
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(`[next-sitemap] Error calling generateStaticParams for ${route.pathname}:`, error);
|
|
158
|
+
}
|
|
159
|
+
} else if (route.dynamicSegments.length > 0) {
|
|
160
|
+
if (debug) {
|
|
161
|
+
console.warn(
|
|
162
|
+
`[next-sitemap] Skipping dynamic route ${route.pathname}: no generateStaticParams exported. Use additionalSitemaps for routes that fetch data at runtime.`
|
|
163
|
+
);
|
|
144
164
|
}
|
|
145
165
|
}
|
|
146
166
|
}
|
|
@@ -170,12 +190,20 @@ function pathsToEntries(paths, config) {
|
|
|
170
190
|
});
|
|
171
191
|
}
|
|
172
192
|
function createSitemapIndexHandler(options) {
|
|
173
|
-
const { urlsPerSitemap = 5e3, locales = [], defaultLocale, additionalSitemaps, exclude } = options;
|
|
193
|
+
const { urlsPerSitemap = 5e3, locales = [], defaultLocale, additionalSitemaps, exclude, debug = false } = options;
|
|
174
194
|
const localeSegment = options.localeSegment ?? (locales.length > 0 || defaultLocale ? "[locale]" : "");
|
|
175
195
|
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
196
|
+
if (debug) {
|
|
197
|
+
console.log(`[next-sitemap] Found ${routes.length} routes:`);
|
|
198
|
+
routes.forEach((r) => {
|
|
199
|
+
const hasParams = r.getParams ? "\u2713 generateStaticParams" : "\u2717 no generateStaticParams";
|
|
200
|
+
const segments = r.dynamicSegments.length > 0 ? ` [${r.dynamicSegments.join(", ")}]` : "";
|
|
201
|
+
console.log(` ${r.pathname}${segments} - ${hasParams}`);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
176
204
|
return {
|
|
177
205
|
GET: async () => {
|
|
178
|
-
const allPaths = await getAllPaths(routes);
|
|
206
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
179
207
|
const filteredPaths = allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
180
208
|
const sitemapCount = Math.max(1, Math.ceil(filteredPaths.length / urlsPerSitemap));
|
|
181
209
|
const xml = generateSitemapIndexXml(options.baseUrl, sitemapCount, {
|
|
@@ -188,11 +216,11 @@ function createSitemapIndexHandler(options) {
|
|
|
188
216
|
};
|
|
189
217
|
}
|
|
190
218
|
function createSitemapHandler(options) {
|
|
191
|
-
const { urlsPerSitemap = 5e3, locales = [], defaultLocale, exclude } = options;
|
|
219
|
+
const { urlsPerSitemap = 5e3, locales = [], defaultLocale, exclude, debug = false } = options;
|
|
192
220
|
const localeSegment = options.localeSegment ?? (locales.length > 0 || defaultLocale ? "[locale]" : "");
|
|
193
221
|
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
194
222
|
const getFilteredPaths = async () => {
|
|
195
|
-
const allPaths = await getAllPaths(routes);
|
|
223
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
196
224
|
return allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
197
225
|
};
|
|
198
226
|
return {
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
function calculateDepthPriority(pathname) {
|
|
5
|
+
if (pathname === "/") return 1;
|
|
6
|
+
const depth = pathname.split("/").filter(Boolean).length;
|
|
7
|
+
return Math.max(0.1, 1 - depth * 0.2);
|
|
8
|
+
}
|
|
9
|
+
function shouldExclude(pathname, exclude) {
|
|
10
|
+
if (!exclude) return false;
|
|
11
|
+
if (typeof exclude === "function") {
|
|
12
|
+
return exclude(pathname);
|
|
13
|
+
}
|
|
14
|
+
return exclude.some((pattern) => {
|
|
15
|
+
const regexPattern = pattern.replace(/\*\*/g, "{{DOUBLE_STAR}}").replace(/\*/g, "[^/]*").replace(/\{\{DOUBLE_STAR\}\}/g, ".*");
|
|
16
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
17
|
+
return regex.test(pathname);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function getPriority(pathname, priority) {
|
|
21
|
+
if (priority === void 0 || priority === "auto") {
|
|
22
|
+
return calculateDepthPriority(pathname);
|
|
23
|
+
}
|
|
24
|
+
if (typeof priority === "function") {
|
|
25
|
+
return priority(pathname);
|
|
26
|
+
}
|
|
27
|
+
return priority;
|
|
28
|
+
}
|
|
29
|
+
function getChangeFreq(pathname, changeFreq) {
|
|
30
|
+
if (changeFreq === void 0) {
|
|
31
|
+
return "weekly";
|
|
32
|
+
}
|
|
33
|
+
if (typeof changeFreq === "function") {
|
|
34
|
+
return changeFreq(pathname);
|
|
35
|
+
}
|
|
36
|
+
return changeFreq;
|
|
37
|
+
}
|
|
38
|
+
function buildUrl(baseUrl, pathname, locale, defaultLocale) {
|
|
39
|
+
if (!locale || locale === defaultLocale) {
|
|
40
|
+
return `${baseUrl}${pathname}`;
|
|
41
|
+
}
|
|
42
|
+
return `${baseUrl}/${locale}${pathname}`;
|
|
43
|
+
}
|
|
44
|
+
function generateSitemapXml(entries) {
|
|
45
|
+
const hasAlternates = entries.some((e) => e.alternates?.languages);
|
|
46
|
+
const xmlns = hasAlternates ? 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"' : 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
|
|
47
|
+
const urlEntries = entries.map((entry) => {
|
|
48
|
+
const parts = [` <loc>${entry.url}</loc>`];
|
|
49
|
+
if (entry.alternates?.languages) {
|
|
50
|
+
for (const [lang, href] of Object.entries(entry.alternates.languages)) {
|
|
51
|
+
parts.push(` <xhtml:link rel="alternate" hreflang="${lang}" href="${href}" />`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (entry.lastModified) {
|
|
55
|
+
const date = entry.lastModified instanceof Date ? entry.lastModified.toISOString() : entry.lastModified;
|
|
56
|
+
parts.push(` <lastmod>${date}</lastmod>`);
|
|
57
|
+
}
|
|
58
|
+
if (entry.changeFrequency) {
|
|
59
|
+
parts.push(` <changefreq>${entry.changeFrequency}</changefreq>`);
|
|
60
|
+
}
|
|
61
|
+
if (entry.priority !== void 0) {
|
|
62
|
+
parts.push(` <priority>${entry.priority}</priority>`);
|
|
63
|
+
}
|
|
64
|
+
return ` <url>
|
|
65
|
+
${parts.join("\n")}
|
|
66
|
+
</url>`;
|
|
67
|
+
}).join("\n");
|
|
68
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
69
|
+
<urlset ${xmlns}>
|
|
70
|
+
${urlEntries}
|
|
71
|
+
</urlset>`;
|
|
72
|
+
}
|
|
73
|
+
function generateSitemapIndexXml(baseUrl, sitemapCount, options) {
|
|
74
|
+
const { sitemapPattern = "/sitemap-{id}.xml", additionalSitemaps = [] } = options || {};
|
|
75
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
76
|
+
const paginatedEntries = Array.from({ length: sitemapCount }, (_, i) => {
|
|
77
|
+
const loc = `${baseUrl}${sitemapPattern.replace("{id}", String(i))}`;
|
|
78
|
+
return ` <sitemap>
|
|
79
|
+
<loc>${loc}</loc>
|
|
80
|
+
<lastmod>${now}</lastmod>
|
|
81
|
+
</sitemap>`;
|
|
82
|
+
});
|
|
83
|
+
const additionalEntries = additionalSitemaps.map((sitemap) => {
|
|
84
|
+
const loc = sitemap.startsWith("http") ? sitemap : `${baseUrl}${sitemap}`;
|
|
85
|
+
return ` <sitemap>
|
|
86
|
+
<loc>${loc}</loc>
|
|
87
|
+
<lastmod>${now}</lastmod>
|
|
88
|
+
</sitemap>`;
|
|
89
|
+
});
|
|
90
|
+
const allEntries = [...paginatedEntries, ...additionalEntries].join("\n");
|
|
91
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
92
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
93
|
+
${allEntries}
|
|
94
|
+
</sitemapindex>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/pages/index.ts
|
|
98
|
+
function extractRoutes(pagesContext, localeSegment) {
|
|
99
|
+
const routes = [];
|
|
100
|
+
for (const key of pagesContext.keys()) {
|
|
101
|
+
if (key.includes("[...")) continue;
|
|
102
|
+
if (key.includes("/api/")) continue;
|
|
103
|
+
if (key.includes("/_")) continue;
|
|
104
|
+
const pageModule = pagesContext(key);
|
|
105
|
+
let pathname = key.replace(/^\.\//, "/").replace(/\.tsx?$/, "").replace(/\/index$/, "/");
|
|
106
|
+
if (pathname === "") pathname = "/";
|
|
107
|
+
if (localeSegment) {
|
|
108
|
+
pathname = pathname.replace(
|
|
109
|
+
new RegExp(`^/${localeSegment.replace(/[[\]]/g, "\\$&")}`),
|
|
110
|
+
""
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (/(?:^|\/)(src|pages)(?:\/|$)/.test(pathname)) continue;
|
|
114
|
+
if (!pathname || pathname === "") {
|
|
115
|
+
pathname = "/";
|
|
116
|
+
} else if (!pathname.startsWith("/")) {
|
|
117
|
+
pathname = "/" + pathname;
|
|
118
|
+
}
|
|
119
|
+
const dynamicSegments = pathname.match(/\[([^\]]+)\]/g)?.map((s) => s.slice(1, -1)) || [];
|
|
120
|
+
const getStaticPaths = pageModule.getStaticPaths;
|
|
121
|
+
let getParams = null;
|
|
122
|
+
if (getStaticPaths) {
|
|
123
|
+
getParams = async () => {
|
|
124
|
+
const result = await getStaticPaths();
|
|
125
|
+
return result.paths.map((p) => p.params);
|
|
126
|
+
};
|
|
127
|
+
} else if (pageModule.generateStaticParams) {
|
|
128
|
+
getParams = pageModule.generateStaticParams;
|
|
129
|
+
}
|
|
130
|
+
routes.push({
|
|
131
|
+
pathname,
|
|
132
|
+
dynamicSegments,
|
|
133
|
+
getParams
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return routes;
|
|
137
|
+
}
|
|
138
|
+
async function getAllPaths(routes, debug = false) {
|
|
139
|
+
const allPaths = ["/"];
|
|
140
|
+
const seenPaths = /* @__PURE__ */ new Set(["/"]);
|
|
141
|
+
for (const route of routes) {
|
|
142
|
+
if (route.dynamicSegments.length === 0) {
|
|
143
|
+
if (route.pathname !== "/" && !seenPaths.has(route.pathname)) {
|
|
144
|
+
allPaths.push(route.pathname);
|
|
145
|
+
seenPaths.add(route.pathname);
|
|
146
|
+
}
|
|
147
|
+
} else if (route.getParams) {
|
|
148
|
+
try {
|
|
149
|
+
const params = await route.getParams();
|
|
150
|
+
if (debug) {
|
|
151
|
+
console.log(`[next-sitemap] ${route.pathname}: getStaticPaths returned ${params.length} params`);
|
|
152
|
+
}
|
|
153
|
+
for (const param of params) {
|
|
154
|
+
let dynamicPath = route.pathname;
|
|
155
|
+
for (const segment of route.dynamicSegments) {
|
|
156
|
+
const value = param[segment];
|
|
157
|
+
if (value === void 0) {
|
|
158
|
+
if (debug) {
|
|
159
|
+
console.warn(`[next-sitemap] ${route.pathname}: missing param "${segment}" in`, param);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
dynamicPath = dynamicPath.replace(`[${segment}]`, value);
|
|
164
|
+
}
|
|
165
|
+
if (!seenPaths.has(dynamicPath)) {
|
|
166
|
+
allPaths.push(dynamicPath);
|
|
167
|
+
seenPaths.add(dynamicPath);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(`[next-sitemap] Error calling getStaticPaths for ${route.pathname}:`, error);
|
|
172
|
+
}
|
|
173
|
+
} else if (route.dynamicSegments.length > 0) {
|
|
174
|
+
if (debug) {
|
|
175
|
+
console.warn(
|
|
176
|
+
`[next-sitemap] Skipping dynamic route ${route.pathname}: no getStaticPaths exported. Use additionalSitemaps for routes that fetch data at runtime.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return allPaths;
|
|
182
|
+
}
|
|
183
|
+
function pathsToEntries(paths, config) {
|
|
184
|
+
const { baseUrl, locales = [], defaultLocale, exclude, priority, changeFreq } = config;
|
|
185
|
+
const filteredPaths = paths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
186
|
+
return filteredPaths.map((pathname) => {
|
|
187
|
+
const entry = {
|
|
188
|
+
url: buildUrl(baseUrl, pathname, defaultLocale, defaultLocale),
|
|
189
|
+
lastModified: /* @__PURE__ */ new Date(),
|
|
190
|
+
changeFrequency: getChangeFreq(pathname, changeFreq),
|
|
191
|
+
priority: getPriority(pathname, priority)
|
|
192
|
+
};
|
|
193
|
+
if (locales.length > 0) {
|
|
194
|
+
entry.alternates = {
|
|
195
|
+
languages: Object.fromEntries(
|
|
196
|
+
locales.map((locale) => [
|
|
197
|
+
locale,
|
|
198
|
+
buildUrl(baseUrl, pathname, locale, defaultLocale)
|
|
199
|
+
])
|
|
200
|
+
)
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return entry;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
function createSitemapIndexApiHandler(options) {
|
|
207
|
+
const { urlsPerSitemap = 5e3, additionalSitemaps, exclude, debug = false } = options;
|
|
208
|
+
const localeSegment = options.localeSegment ?? "";
|
|
209
|
+
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
210
|
+
if (debug) {
|
|
211
|
+
console.log(`[next-sitemap] Found ${routes.length} routes:`);
|
|
212
|
+
routes.forEach((r) => {
|
|
213
|
+
const hasParams = r.getParams ? "\u2713 getStaticPaths" : "\u2717 no getStaticPaths";
|
|
214
|
+
const segments = r.dynamicSegments.length > 0 ? ` [${r.dynamicSegments.join(", ")}]` : "";
|
|
215
|
+
console.log(` ${r.pathname}${segments} - ${hasParams}`);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return async function handler(_req, res) {
|
|
219
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
220
|
+
const filteredPaths = allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
221
|
+
const sitemapCount = Math.max(1, Math.ceil(filteredPaths.length / urlsPerSitemap));
|
|
222
|
+
const xml = generateSitemapIndexXml(options.baseUrl, sitemapCount, {
|
|
223
|
+
additionalSitemaps
|
|
224
|
+
});
|
|
225
|
+
res.setHeader("Content-Type", "application/xml");
|
|
226
|
+
res.status(200).send(xml);
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function createSitemapApiHandler(options) {
|
|
230
|
+
const { urlsPerSitemap = 5e3, exclude, debug = false } = options;
|
|
231
|
+
const localeSegment = options.localeSegment ?? "";
|
|
232
|
+
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
233
|
+
const getFilteredPaths = async () => {
|
|
234
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
235
|
+
return allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
236
|
+
};
|
|
237
|
+
return async function handler(req, res) {
|
|
238
|
+
const { id } = req.query;
|
|
239
|
+
const sitemapId = parseInt(Array.isArray(id) ? id[0] : id || "0", 10);
|
|
240
|
+
const filteredPaths = await getFilteredPaths();
|
|
241
|
+
const start = sitemapId * urlsPerSitemap;
|
|
242
|
+
const end = start + urlsPerSitemap;
|
|
243
|
+
const paths = filteredPaths.slice(start, end);
|
|
244
|
+
const entries = pathsToEntries(paths, { ...options, exclude: void 0 });
|
|
245
|
+
const xml = generateSitemapXml(entries);
|
|
246
|
+
res.setHeader("Content-Type", "application/xml");
|
|
247
|
+
res.status(200).send(xml);
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async function getSitemapStaticPaths(options) {
|
|
251
|
+
const { urlsPerSitemap = 5e3, exclude, debug = false } = options;
|
|
252
|
+
const localeSegment = options.localeSegment ?? "";
|
|
253
|
+
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
254
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
255
|
+
const filteredPaths = allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
256
|
+
const sitemapCount = Math.max(1, Math.ceil(filteredPaths.length / urlsPerSitemap));
|
|
257
|
+
return {
|
|
258
|
+
paths: Array.from({ length: sitemapCount }, (_, i) => ({
|
|
259
|
+
params: { id: String(i) }
|
|
260
|
+
})),
|
|
261
|
+
fallback: false
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
exports.createSitemapApiHandler = createSitemapApiHandler;
|
|
266
|
+
exports.createSitemapIndexApiHandler = createSitemapIndexApiHandler;
|
|
267
|
+
exports.getSitemapStaticPaths = getSitemapStaticPaths;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
|
|
3
|
+
type ChangeFrequency = "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
4
|
+
interface SitemapConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Base URL of the site (e.g., "https://example.com")
|
|
7
|
+
*/
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
/**
|
|
10
|
+
* List of supported locales (e.g., ["en", "fr"])
|
|
11
|
+
*/
|
|
12
|
+
locales?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Default locale (e.g., "en")
|
|
15
|
+
*/
|
|
16
|
+
defaultLocale?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Number of URLs per sitemap file (default: 5000)
|
|
19
|
+
*/
|
|
20
|
+
urlsPerSitemap?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Path to the app directory relative to the project root
|
|
23
|
+
* Default: "./src/app" or "./app"
|
|
24
|
+
*/
|
|
25
|
+
appDir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Locale segment in the URL path (e.g., "[locale]")
|
|
28
|
+
* Auto-detected as "[locale]" when locales or defaultLocale is set
|
|
29
|
+
*/
|
|
30
|
+
localeSegment?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Exclude routes from the sitemap
|
|
33
|
+
* Can be an array of glob patterns or a function that returns true to exclude
|
|
34
|
+
* @example exclude: ["/admin/*", "/api/*"]
|
|
35
|
+
* @example exclude: (path) => path.startsWith("/private")
|
|
36
|
+
*/
|
|
37
|
+
exclude?: string[] | ((path: string) => boolean);
|
|
38
|
+
/**
|
|
39
|
+
* Priority for sitemap entries
|
|
40
|
+
* Can be a fixed number (0.0-1.0), a function, or "auto" for depth-based calculation
|
|
41
|
+
* Default: "auto" (1.0 - depth * 0.2, minimum 0.1)
|
|
42
|
+
* @example priority: 0.8
|
|
43
|
+
* @example priority: (path) => path === "/" ? 1.0 : 0.7
|
|
44
|
+
* @example priority: "auto"
|
|
45
|
+
*/
|
|
46
|
+
priority?: number | "auto" | ((path: string) => number);
|
|
47
|
+
/**
|
|
48
|
+
* Change frequency for sitemap entries
|
|
49
|
+
* Can be a fixed value or a function
|
|
50
|
+
* Default: "weekly"
|
|
51
|
+
* @example changeFreq: "daily"
|
|
52
|
+
* @example changeFreq: (path) => path === "/" ? "daily" : "weekly"
|
|
53
|
+
*/
|
|
54
|
+
changeFreq?: ChangeFrequency | ((path: string) => ChangeFrequency);
|
|
55
|
+
/**
|
|
56
|
+
* Additional sitemaps to include in the sitemap index
|
|
57
|
+
* Useful for custom sitemaps (e.g., products from an API)
|
|
58
|
+
* @example additionalSitemaps: ["/products-sitemap.xml", "/blog-sitemap.xml"]
|
|
59
|
+
*/
|
|
60
|
+
additionalSitemaps?: string[];
|
|
61
|
+
}
|
|
62
|
+
interface SitemapEntry {
|
|
63
|
+
url: string;
|
|
64
|
+
lastModified?: string | Date;
|
|
65
|
+
changeFrequency?: ChangeFrequency;
|
|
66
|
+
priority?: number;
|
|
67
|
+
alternates?: {
|
|
68
|
+
languages?: Record<string, string>;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
type PageModule = {
|
|
72
|
+
generateStaticParams?: () => Promise<Record<string, string>[]>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
interface CreateSitemapApiHandlerOptions extends SitemapConfig {
|
|
76
|
+
/**
|
|
77
|
+
* The require.context result for page discovery
|
|
78
|
+
* Example: require.context('./', true, /^\.\/(?!\[|_|api\/).*\.tsx$/)
|
|
79
|
+
*/
|
|
80
|
+
pagesContext: {
|
|
81
|
+
keys: () => string[];
|
|
82
|
+
(key: string): PageModule;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Enable debug logging to diagnose issues with route discovery
|
|
86
|
+
* Logs info about getStaticPaths calls and skipped routes
|
|
87
|
+
* @default false
|
|
88
|
+
*/
|
|
89
|
+
debug?: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create API handler for sitemap index route
|
|
93
|
+
* Use in: pages/api/sitemap.xml.ts or pages/sitemap.xml.ts (with rewrites)
|
|
94
|
+
*/
|
|
95
|
+
declare function createSitemapIndexApiHandler(options: CreateSitemapApiHandlerOptions): (_req: NextApiRequest, res: NextApiResponse) => Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Create API handler for individual sitemap routes
|
|
98
|
+
* Use in: pages/api/sitemap/[id].ts or pages/sitemap-[id].xml.ts (with rewrites)
|
|
99
|
+
*/
|
|
100
|
+
declare function createSitemapApiHandler(options: CreateSitemapApiHandlerOptions): (req: NextApiRequest, res: NextApiResponse) => Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Get the list of sitemap IDs for getStaticPaths
|
|
103
|
+
* Use this to pre-render sitemap pages at build time
|
|
104
|
+
*/
|
|
105
|
+
declare function getSitemapStaticPaths(options: CreateSitemapApiHandlerOptions): Promise<{
|
|
106
|
+
paths: {
|
|
107
|
+
params: {
|
|
108
|
+
id: string;
|
|
109
|
+
};
|
|
110
|
+
}[];
|
|
111
|
+
fallback: boolean;
|
|
112
|
+
}>;
|
|
113
|
+
|
|
114
|
+
export { type ChangeFrequency, type CreateSitemapApiHandlerOptions, type SitemapConfig, type SitemapEntry, createSitemapApiHandler, createSitemapIndexApiHandler, getSitemapStaticPaths };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
|
|
3
|
+
type ChangeFrequency = "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
|
4
|
+
interface SitemapConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Base URL of the site (e.g., "https://example.com")
|
|
7
|
+
*/
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
/**
|
|
10
|
+
* List of supported locales (e.g., ["en", "fr"])
|
|
11
|
+
*/
|
|
12
|
+
locales?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Default locale (e.g., "en")
|
|
15
|
+
*/
|
|
16
|
+
defaultLocale?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Number of URLs per sitemap file (default: 5000)
|
|
19
|
+
*/
|
|
20
|
+
urlsPerSitemap?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Path to the app directory relative to the project root
|
|
23
|
+
* Default: "./src/app" or "./app"
|
|
24
|
+
*/
|
|
25
|
+
appDir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Locale segment in the URL path (e.g., "[locale]")
|
|
28
|
+
* Auto-detected as "[locale]" when locales or defaultLocale is set
|
|
29
|
+
*/
|
|
30
|
+
localeSegment?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Exclude routes from the sitemap
|
|
33
|
+
* Can be an array of glob patterns or a function that returns true to exclude
|
|
34
|
+
* @example exclude: ["/admin/*", "/api/*"]
|
|
35
|
+
* @example exclude: (path) => path.startsWith("/private")
|
|
36
|
+
*/
|
|
37
|
+
exclude?: string[] | ((path: string) => boolean);
|
|
38
|
+
/**
|
|
39
|
+
* Priority for sitemap entries
|
|
40
|
+
* Can be a fixed number (0.0-1.0), a function, or "auto" for depth-based calculation
|
|
41
|
+
* Default: "auto" (1.0 - depth * 0.2, minimum 0.1)
|
|
42
|
+
* @example priority: 0.8
|
|
43
|
+
* @example priority: (path) => path === "/" ? 1.0 : 0.7
|
|
44
|
+
* @example priority: "auto"
|
|
45
|
+
*/
|
|
46
|
+
priority?: number | "auto" | ((path: string) => number);
|
|
47
|
+
/**
|
|
48
|
+
* Change frequency for sitemap entries
|
|
49
|
+
* Can be a fixed value or a function
|
|
50
|
+
* Default: "weekly"
|
|
51
|
+
* @example changeFreq: "daily"
|
|
52
|
+
* @example changeFreq: (path) => path === "/" ? "daily" : "weekly"
|
|
53
|
+
*/
|
|
54
|
+
changeFreq?: ChangeFrequency | ((path: string) => ChangeFrequency);
|
|
55
|
+
/**
|
|
56
|
+
* Additional sitemaps to include in the sitemap index
|
|
57
|
+
* Useful for custom sitemaps (e.g., products from an API)
|
|
58
|
+
* @example additionalSitemaps: ["/products-sitemap.xml", "/blog-sitemap.xml"]
|
|
59
|
+
*/
|
|
60
|
+
additionalSitemaps?: string[];
|
|
61
|
+
}
|
|
62
|
+
interface SitemapEntry {
|
|
63
|
+
url: string;
|
|
64
|
+
lastModified?: string | Date;
|
|
65
|
+
changeFrequency?: ChangeFrequency;
|
|
66
|
+
priority?: number;
|
|
67
|
+
alternates?: {
|
|
68
|
+
languages?: Record<string, string>;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
type PageModule = {
|
|
72
|
+
generateStaticParams?: () => Promise<Record<string, string>[]>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
interface CreateSitemapApiHandlerOptions extends SitemapConfig {
|
|
76
|
+
/**
|
|
77
|
+
* The require.context result for page discovery
|
|
78
|
+
* Example: require.context('./', true, /^\.\/(?!\[|_|api\/).*\.tsx$/)
|
|
79
|
+
*/
|
|
80
|
+
pagesContext: {
|
|
81
|
+
keys: () => string[];
|
|
82
|
+
(key: string): PageModule;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Enable debug logging to diagnose issues with route discovery
|
|
86
|
+
* Logs info about getStaticPaths calls and skipped routes
|
|
87
|
+
* @default false
|
|
88
|
+
*/
|
|
89
|
+
debug?: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create API handler for sitemap index route
|
|
93
|
+
* Use in: pages/api/sitemap.xml.ts or pages/sitemap.xml.ts (with rewrites)
|
|
94
|
+
*/
|
|
95
|
+
declare function createSitemapIndexApiHandler(options: CreateSitemapApiHandlerOptions): (_req: NextApiRequest, res: NextApiResponse) => Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Create API handler for individual sitemap routes
|
|
98
|
+
* Use in: pages/api/sitemap/[id].ts or pages/sitemap-[id].xml.ts (with rewrites)
|
|
99
|
+
*/
|
|
100
|
+
declare function createSitemapApiHandler(options: CreateSitemapApiHandlerOptions): (req: NextApiRequest, res: NextApiResponse) => Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Get the list of sitemap IDs for getStaticPaths
|
|
103
|
+
* Use this to pre-render sitemap pages at build time
|
|
104
|
+
*/
|
|
105
|
+
declare function getSitemapStaticPaths(options: CreateSitemapApiHandlerOptions): Promise<{
|
|
106
|
+
paths: {
|
|
107
|
+
params: {
|
|
108
|
+
id: string;
|
|
109
|
+
};
|
|
110
|
+
}[];
|
|
111
|
+
fallback: boolean;
|
|
112
|
+
}>;
|
|
113
|
+
|
|
114
|
+
export { type ChangeFrequency, type CreateSitemapApiHandlerOptions, type SitemapConfig, type SitemapEntry, createSitemapApiHandler, createSitemapIndexApiHandler, getSitemapStaticPaths };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function calculateDepthPriority(pathname) {
|
|
3
|
+
if (pathname === "/") return 1;
|
|
4
|
+
const depth = pathname.split("/").filter(Boolean).length;
|
|
5
|
+
return Math.max(0.1, 1 - depth * 0.2);
|
|
6
|
+
}
|
|
7
|
+
function shouldExclude(pathname, exclude) {
|
|
8
|
+
if (!exclude) return false;
|
|
9
|
+
if (typeof exclude === "function") {
|
|
10
|
+
return exclude(pathname);
|
|
11
|
+
}
|
|
12
|
+
return exclude.some((pattern) => {
|
|
13
|
+
const regexPattern = pattern.replace(/\*\*/g, "{{DOUBLE_STAR}}").replace(/\*/g, "[^/]*").replace(/\{\{DOUBLE_STAR\}\}/g, ".*");
|
|
14
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
15
|
+
return regex.test(pathname);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function getPriority(pathname, priority) {
|
|
19
|
+
if (priority === void 0 || priority === "auto") {
|
|
20
|
+
return calculateDepthPriority(pathname);
|
|
21
|
+
}
|
|
22
|
+
if (typeof priority === "function") {
|
|
23
|
+
return priority(pathname);
|
|
24
|
+
}
|
|
25
|
+
return priority;
|
|
26
|
+
}
|
|
27
|
+
function getChangeFreq(pathname, changeFreq) {
|
|
28
|
+
if (changeFreq === void 0) {
|
|
29
|
+
return "weekly";
|
|
30
|
+
}
|
|
31
|
+
if (typeof changeFreq === "function") {
|
|
32
|
+
return changeFreq(pathname);
|
|
33
|
+
}
|
|
34
|
+
return changeFreq;
|
|
35
|
+
}
|
|
36
|
+
function buildUrl(baseUrl, pathname, locale, defaultLocale) {
|
|
37
|
+
if (!locale || locale === defaultLocale) {
|
|
38
|
+
return `${baseUrl}${pathname}`;
|
|
39
|
+
}
|
|
40
|
+
return `${baseUrl}/${locale}${pathname}`;
|
|
41
|
+
}
|
|
42
|
+
function generateSitemapXml(entries) {
|
|
43
|
+
const hasAlternates = entries.some((e) => e.alternates?.languages);
|
|
44
|
+
const xmlns = hasAlternates ? 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"' : 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
|
|
45
|
+
const urlEntries = entries.map((entry) => {
|
|
46
|
+
const parts = [` <loc>${entry.url}</loc>`];
|
|
47
|
+
if (entry.alternates?.languages) {
|
|
48
|
+
for (const [lang, href] of Object.entries(entry.alternates.languages)) {
|
|
49
|
+
parts.push(` <xhtml:link rel="alternate" hreflang="${lang}" href="${href}" />`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (entry.lastModified) {
|
|
53
|
+
const date = entry.lastModified instanceof Date ? entry.lastModified.toISOString() : entry.lastModified;
|
|
54
|
+
parts.push(` <lastmod>${date}</lastmod>`);
|
|
55
|
+
}
|
|
56
|
+
if (entry.changeFrequency) {
|
|
57
|
+
parts.push(` <changefreq>${entry.changeFrequency}</changefreq>`);
|
|
58
|
+
}
|
|
59
|
+
if (entry.priority !== void 0) {
|
|
60
|
+
parts.push(` <priority>${entry.priority}</priority>`);
|
|
61
|
+
}
|
|
62
|
+
return ` <url>
|
|
63
|
+
${parts.join("\n")}
|
|
64
|
+
</url>`;
|
|
65
|
+
}).join("\n");
|
|
66
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
67
|
+
<urlset ${xmlns}>
|
|
68
|
+
${urlEntries}
|
|
69
|
+
</urlset>`;
|
|
70
|
+
}
|
|
71
|
+
function generateSitemapIndexXml(baseUrl, sitemapCount, options) {
|
|
72
|
+
const { sitemapPattern = "/sitemap-{id}.xml", additionalSitemaps = [] } = options || {};
|
|
73
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
74
|
+
const paginatedEntries = Array.from({ length: sitemapCount }, (_, i) => {
|
|
75
|
+
const loc = `${baseUrl}${sitemapPattern.replace("{id}", String(i))}`;
|
|
76
|
+
return ` <sitemap>
|
|
77
|
+
<loc>${loc}</loc>
|
|
78
|
+
<lastmod>${now}</lastmod>
|
|
79
|
+
</sitemap>`;
|
|
80
|
+
});
|
|
81
|
+
const additionalEntries = additionalSitemaps.map((sitemap) => {
|
|
82
|
+
const loc = sitemap.startsWith("http") ? sitemap : `${baseUrl}${sitemap}`;
|
|
83
|
+
return ` <sitemap>
|
|
84
|
+
<loc>${loc}</loc>
|
|
85
|
+
<lastmod>${now}</lastmod>
|
|
86
|
+
</sitemap>`;
|
|
87
|
+
});
|
|
88
|
+
const allEntries = [...paginatedEntries, ...additionalEntries].join("\n");
|
|
89
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
90
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
91
|
+
${allEntries}
|
|
92
|
+
</sitemapindex>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/pages/index.ts
|
|
96
|
+
function extractRoutes(pagesContext, localeSegment) {
|
|
97
|
+
const routes = [];
|
|
98
|
+
for (const key of pagesContext.keys()) {
|
|
99
|
+
if (key.includes("[...")) continue;
|
|
100
|
+
if (key.includes("/api/")) continue;
|
|
101
|
+
if (key.includes("/_")) continue;
|
|
102
|
+
const pageModule = pagesContext(key);
|
|
103
|
+
let pathname = key.replace(/^\.\//, "/").replace(/\.tsx?$/, "").replace(/\/index$/, "/");
|
|
104
|
+
if (pathname === "") pathname = "/";
|
|
105
|
+
if (localeSegment) {
|
|
106
|
+
pathname = pathname.replace(
|
|
107
|
+
new RegExp(`^/${localeSegment.replace(/[[\]]/g, "\\$&")}`),
|
|
108
|
+
""
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (/(?:^|\/)(src|pages)(?:\/|$)/.test(pathname)) continue;
|
|
112
|
+
if (!pathname || pathname === "") {
|
|
113
|
+
pathname = "/";
|
|
114
|
+
} else if (!pathname.startsWith("/")) {
|
|
115
|
+
pathname = "/" + pathname;
|
|
116
|
+
}
|
|
117
|
+
const dynamicSegments = pathname.match(/\[([^\]]+)\]/g)?.map((s) => s.slice(1, -1)) || [];
|
|
118
|
+
const getStaticPaths = pageModule.getStaticPaths;
|
|
119
|
+
let getParams = null;
|
|
120
|
+
if (getStaticPaths) {
|
|
121
|
+
getParams = async () => {
|
|
122
|
+
const result = await getStaticPaths();
|
|
123
|
+
return result.paths.map((p) => p.params);
|
|
124
|
+
};
|
|
125
|
+
} else if (pageModule.generateStaticParams) {
|
|
126
|
+
getParams = pageModule.generateStaticParams;
|
|
127
|
+
}
|
|
128
|
+
routes.push({
|
|
129
|
+
pathname,
|
|
130
|
+
dynamicSegments,
|
|
131
|
+
getParams
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return routes;
|
|
135
|
+
}
|
|
136
|
+
async function getAllPaths(routes, debug = false) {
|
|
137
|
+
const allPaths = ["/"];
|
|
138
|
+
const seenPaths = /* @__PURE__ */ new Set(["/"]);
|
|
139
|
+
for (const route of routes) {
|
|
140
|
+
if (route.dynamicSegments.length === 0) {
|
|
141
|
+
if (route.pathname !== "/" && !seenPaths.has(route.pathname)) {
|
|
142
|
+
allPaths.push(route.pathname);
|
|
143
|
+
seenPaths.add(route.pathname);
|
|
144
|
+
}
|
|
145
|
+
} else if (route.getParams) {
|
|
146
|
+
try {
|
|
147
|
+
const params = await route.getParams();
|
|
148
|
+
if (debug) {
|
|
149
|
+
console.log(`[next-sitemap] ${route.pathname}: getStaticPaths returned ${params.length} params`);
|
|
150
|
+
}
|
|
151
|
+
for (const param of params) {
|
|
152
|
+
let dynamicPath = route.pathname;
|
|
153
|
+
for (const segment of route.dynamicSegments) {
|
|
154
|
+
const value = param[segment];
|
|
155
|
+
if (value === void 0) {
|
|
156
|
+
if (debug) {
|
|
157
|
+
console.warn(`[next-sitemap] ${route.pathname}: missing param "${segment}" in`, param);
|
|
158
|
+
}
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
dynamicPath = dynamicPath.replace(`[${segment}]`, value);
|
|
162
|
+
}
|
|
163
|
+
if (!seenPaths.has(dynamicPath)) {
|
|
164
|
+
allPaths.push(dynamicPath);
|
|
165
|
+
seenPaths.add(dynamicPath);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.error(`[next-sitemap] Error calling getStaticPaths for ${route.pathname}:`, error);
|
|
170
|
+
}
|
|
171
|
+
} else if (route.dynamicSegments.length > 0) {
|
|
172
|
+
if (debug) {
|
|
173
|
+
console.warn(
|
|
174
|
+
`[next-sitemap] Skipping dynamic route ${route.pathname}: no getStaticPaths exported. Use additionalSitemaps for routes that fetch data at runtime.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return allPaths;
|
|
180
|
+
}
|
|
181
|
+
function pathsToEntries(paths, config) {
|
|
182
|
+
const { baseUrl, locales = [], defaultLocale, exclude, priority, changeFreq } = config;
|
|
183
|
+
const filteredPaths = paths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
184
|
+
return filteredPaths.map((pathname) => {
|
|
185
|
+
const entry = {
|
|
186
|
+
url: buildUrl(baseUrl, pathname, defaultLocale, defaultLocale),
|
|
187
|
+
lastModified: /* @__PURE__ */ new Date(),
|
|
188
|
+
changeFrequency: getChangeFreq(pathname, changeFreq),
|
|
189
|
+
priority: getPriority(pathname, priority)
|
|
190
|
+
};
|
|
191
|
+
if (locales.length > 0) {
|
|
192
|
+
entry.alternates = {
|
|
193
|
+
languages: Object.fromEntries(
|
|
194
|
+
locales.map((locale) => [
|
|
195
|
+
locale,
|
|
196
|
+
buildUrl(baseUrl, pathname, locale, defaultLocale)
|
|
197
|
+
])
|
|
198
|
+
)
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return entry;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function createSitemapIndexApiHandler(options) {
|
|
205
|
+
const { urlsPerSitemap = 5e3, additionalSitemaps, exclude, debug = false } = options;
|
|
206
|
+
const localeSegment = options.localeSegment ?? "";
|
|
207
|
+
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
208
|
+
if (debug) {
|
|
209
|
+
console.log(`[next-sitemap] Found ${routes.length} routes:`);
|
|
210
|
+
routes.forEach((r) => {
|
|
211
|
+
const hasParams = r.getParams ? "\u2713 getStaticPaths" : "\u2717 no getStaticPaths";
|
|
212
|
+
const segments = r.dynamicSegments.length > 0 ? ` [${r.dynamicSegments.join(", ")}]` : "";
|
|
213
|
+
console.log(` ${r.pathname}${segments} - ${hasParams}`);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return async function handler(_req, res) {
|
|
217
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
218
|
+
const filteredPaths = allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
219
|
+
const sitemapCount = Math.max(1, Math.ceil(filteredPaths.length / urlsPerSitemap));
|
|
220
|
+
const xml = generateSitemapIndexXml(options.baseUrl, sitemapCount, {
|
|
221
|
+
additionalSitemaps
|
|
222
|
+
});
|
|
223
|
+
res.setHeader("Content-Type", "application/xml");
|
|
224
|
+
res.status(200).send(xml);
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
function createSitemapApiHandler(options) {
|
|
228
|
+
const { urlsPerSitemap = 5e3, exclude, debug = false } = options;
|
|
229
|
+
const localeSegment = options.localeSegment ?? "";
|
|
230
|
+
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
231
|
+
const getFilteredPaths = async () => {
|
|
232
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
233
|
+
return allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
234
|
+
};
|
|
235
|
+
return async function handler(req, res) {
|
|
236
|
+
const { id } = req.query;
|
|
237
|
+
const sitemapId = parseInt(Array.isArray(id) ? id[0] : id || "0", 10);
|
|
238
|
+
const filteredPaths = await getFilteredPaths();
|
|
239
|
+
const start = sitemapId * urlsPerSitemap;
|
|
240
|
+
const end = start + urlsPerSitemap;
|
|
241
|
+
const paths = filteredPaths.slice(start, end);
|
|
242
|
+
const entries = pathsToEntries(paths, { ...options, exclude: void 0 });
|
|
243
|
+
const xml = generateSitemapXml(entries);
|
|
244
|
+
res.setHeader("Content-Type", "application/xml");
|
|
245
|
+
res.status(200).send(xml);
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
async function getSitemapStaticPaths(options) {
|
|
249
|
+
const { urlsPerSitemap = 5e3, exclude, debug = false } = options;
|
|
250
|
+
const localeSegment = options.localeSegment ?? "";
|
|
251
|
+
const routes = extractRoutes(options.pagesContext, localeSegment);
|
|
252
|
+
const allPaths = await getAllPaths(routes, debug);
|
|
253
|
+
const filteredPaths = allPaths.filter((pathname) => !shouldExclude(pathname, exclude));
|
|
254
|
+
const sitemapCount = Math.max(1, Math.ceil(filteredPaths.length / urlsPerSitemap));
|
|
255
|
+
return {
|
|
256
|
+
paths: Array.from({ length: sitemapCount }, (_, i) => ({
|
|
257
|
+
params: { id: String(i) }
|
|
258
|
+
})),
|
|
259
|
+
fallback: false
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export { createSitemapApiHandler, createSitemapIndexApiHandler, getSitemapStaticPaths };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onruntime/next-sitemap",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Dynamic sitemap generation for Next.js with automatic route discovery",
|
|
5
5
|
"author": "onRuntime Studio <contact@onruntime.com>",
|
|
6
6
|
"repository": {
|
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
"types": "./dist/app/index.d.ts",
|
|
29
29
|
"import": "./dist/app/index.js",
|
|
30
30
|
"require": "./dist/app/index.cjs"
|
|
31
|
+
},
|
|
32
|
+
"./pages": {
|
|
33
|
+
"types": "./dist/pages/index.d.ts",
|
|
34
|
+
"import": "./dist/pages/index.js",
|
|
35
|
+
"require": "./dist/pages/index.cjs"
|
|
31
36
|
}
|
|
32
37
|
},
|
|
33
38
|
"files": [
|
|
@@ -46,6 +51,7 @@
|
|
|
46
51
|
"sitemap",
|
|
47
52
|
"nextjs",
|
|
48
53
|
"app-router",
|
|
54
|
+
"pages-router",
|
|
49
55
|
"seo",
|
|
50
56
|
"xml",
|
|
51
57
|
"dynamic-sitemap"
|