@onruntime/next-sitemap 0.3.0 → 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 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 |
@@ -192,9 +279,9 @@ const { GET } = createSitemapIndexHandler({
192
279
 
193
280
  ### How It Works
194
281
 
195
- 1. `require.context` scans your app directory at build time
282
+ 1. `require.context` scans your app/pages directory at build time
196
283
  2. For each page found, it extracts the route path
197
- 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)
198
285
  4. URLs are paginated into multiple sitemaps (default: 5000 URLs each)
199
286
  5. A sitemap index lists all individual sitemaps
200
287
 
@@ -288,11 +375,14 @@ This will log:
288
375
 
289
376
  **Common issues:**
290
377
 
291
- 1. **`generateStaticParams` not detected**: Make sure it's exported as a named export at the top level of your page file:
378
+ 1. **`generateStaticParams`/`getStaticPaths` not detected**: Make sure it's exported at the top level of your page file:
292
379
  ```typescript
293
- // ✅ Correct
380
+ // ✅ Correct (App Router)
294
381
  export async function generateStaticParams() { ... }
295
382
 
383
+ // ✅ Correct (Pages Router)
384
+ export async function getStaticPaths() { ... }
385
+
296
386
  // ❌ Wrong - not exported
297
387
  async function generateStaticParams() { ... }
298
388
  ```
@@ -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.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"