Skip to content

Commit b215253

Browse files
SH4LINjustlevine
andauthored
feat: sitemap implementation (#166)
* feature: add sitemap services, utils and parsers. add sitemap path to nextJS. * refactor: update test cases * refactor: update the function names, fix static sitemap generation * refactor: revert logger test case fixes * fix: NODE_ENV type * feat: add time bases revalidation * feat: keep time based revalidation to 0 so sitemaps are generated dynamically always * refactor: remove duplicated deps, rename sitemap.config to sitemapConfig, explicitly mentioning exporting members from script * chore: add link to sitemap() docblock * refactor: config keys. * refactor: replace @link annotation with @see * refactor: replace export { generateSitemap } with function * refactor: rename sitemap_index.xml to sitemap.xml, move SitemapDataFromXML from types package to types.ts * refactor: remove types from @return and @param * refactor: rename sitemap.xml to sitemap_index.xml because paths are conflicting * refactor: rename sitemap_index.xml to wp-sitemap.xml * doc: add sitemap.md doc and update config-api.md doc * chore: add unit tests for the next sitemap module * chore: update test cases for sitemap generation, wrap getSitemapPaths in try/catch block * chore: npm run format * chore: cleanup * chore: npm run format * chore: update docs * docs: modify doc to make it more accurate * docs: clarify `SitemapConfig.customPaths` * fix: deep merge issue with sitemap * doc: add link to the MetadataRoute.SiteMap interface * chore: update docs format * fix: test cases * refactor: remove if-else-if ladder --------- Co-authored-by: Dovid Levine <david@axepress.dev>
1 parent 95d3615 commit b215253

File tree

22 files changed

+1837
-7
lines changed

22 files changed

+1837
-7
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ SnapWP provides several plugins, packages, and libraries that can be used indivi
6161
- [Template Rendering System](docs/template-rendering.md)
6262
- [Handling HTTP Status Codes](docs/http-status-codes.md)
6363
- [Resolving CORS Issues](docs/cors.md)
64+
- [Handling Sitemap Generation](docs/sitemap.md)
65+
- [Using the Query Engine](docs/query-engine.md)
6466

6567
## Development & Contributing
6668

docs/config-api.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Here are the available configuration options:
6969
| `blockDefinitions` | `BlockDefinitions` | [blocks](../packages/blocks/src/blocks/index.ts) | Block definitions for the editor.<br />[Learn more](./overloading-wp-behavior.md#overloading-blocks) |
7070
| `parserOptions` | `HTMLReactParserOptions` | [defaultOptions](../packages/next/src/react-parser/options.tsx) | The default options for the `html-react-parser` library.<br />[Learn more](./overloading-wp-behavior.md#2-pass-customparseroptions-to-overload) |
7171
| `query` | `QueryEngine` | [ApolloClientEngine](../packages/plugin-apollo-client/src/engine/index.ts) | Configuration for the GraphQL query engine.<br />See below for more details on how to customize it.[Learn more](./query-engine.md) |
72+
| `sitemap` | `SitemapConfig` | undefined | Configuration for the sitemap plugin.<br />[Learn more](./sitemap.md) |
7273

7374
Config values are available via their respective keys in the `getConfig()` function.
7475

@@ -139,6 +140,16 @@ export default config;
139140
140141
This configuration allows you to replace the default engine with your custom engine. For more information about creating a custom query engine, see [Creating a Custom Query Engine](./query-engine.md#creating-a-custom-query-engine).
141142
143+
### `sitemap` Configuration
144+
145+
Sitemap behavior is controllable vis the `sitemap` configuration object. The following are the available options:
146+
147+
| **Property** | **Type** | **Default Value** | **Description** |
148+
| ---------------- | ------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
149+
| `indexUri` | `string` | `/wp-sitemap.xml` | The URI of the WordPress index sitemap. |
150+
| `ignorePatterns` | `string[]` | `undefined` | An array of regex patterns used to exclude specific URLs from the sitemap.<br /><br />**Important:** These patterns are matched against the WordPress URLs of each post or page — not your frontend (e.g., Next.js) URLs. For example, if your WordPress site is hosted at `https://example.com` and a post URL is `https://example.com/blog/my-post`, a pattern like `/\/blog\//` or `/my-post$/` will work, while `/localhost:3000/blog/my-post/` will not. |
151+
| `customPaths` | `Record<string, SitemapData[]>` | `undefined` | Arrays of SitemapData, keyed to their sub-sitemap names. This allows you to add custom or overload WordPress-generated sub-sitemaps.<br /> <br />The keys should match the sub-sitemap names as defined in the WordPress sitemap index. For example, if the WordPress sub-sitemap URL you are overriding is `http://mysite.local/wp-sitemap-posts-page-1.xml`, the key should be `wp-sitemap-posts-page-1`.<br /> <br />The `SitemapData` is a subset of [NextJS's MetadataRoute.SiteMap](https://github.com/vercel/next.js/blob/47eda30f1ab5ff8fc97802643125b4ce19cac14e/packages/next/src/lib/metadata/types/metadata-interface.ts#L687). The `images` and `videos` properties are _not_ supported. |
152+
142153
## Integration with `next.config.ts`
143154
144155
SnapWP extends the Next.js configuration using the `withSnapWP` function to configure certain settings automatically based on your Config API, such as using the WordPress URL for [`images.remotePatterns`](https://nextjs.org/docs/app/api-reference/components/image#remotepatterns).

docs/sitemap.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Sitemap
2+
3+
By default, SnapWP uses WordPress's built-in sitemap as the source of truth for generating sitemaps. The sitemap is built using data fetched directly from your WordPress backend and updates automatically.
4+
5+
## Index Sitemap
6+
7+
In the example Next.js starter app, the main sitemap is available at `/wp-sitemap.xml`, just like WordPress default.
8+
9+
### How to Change the Index Sitemap Path in Next.js
10+
11+
If you'd like to change the path (e.g., to `/sitemap_index.xml`), follow these steps:
12+
13+
1. Create a directory in `app/` named `sitemap_index.xml`.
14+
2. Inside it, create a file named `route.ts`.
15+
3. Add the following logic:
16+
17+
```ts
18+
// route.ts
19+
20+
import { NextResponse } from 'next/server';
21+
import { generateIndexSitemap } from '@snapwp/next';
22+
23+
export async function GET(): Promise< NextResponse > {
24+
const sitemaps = await generateIndexSitemap();
25+
26+
const xml = buildXmlFromSitemaps( sitemaps ); // Build <sitemapindex> XML from list
27+
28+
return new NextResponse( xml, {
29+
headers: {
30+
'Content-Type': 'application/xml',
31+
},
32+
} );
33+
}
34+
```
35+
36+
> [!Note]
37+
> The function `generateIndexSitemap()` returns an array of sitemap objects. You can map over them to build the final XML structure.
38+
39+
## Sub-sitemaps
40+
41+
SnapWP supports automatic generation of sub-sitemaps (for posts, pages, custom post types, etc.) using the `app/sitemap.ts` file.
42+
43+
### File: `app/sitemap.ts`
44+
45+
```ts
46+
// app/sitemap.ts
47+
48+
import type { MetadataRoute } from 'next';
49+
import { getSitemapPaths, generateSubSitemaps } from '@snapwp/next';
50+
51+
// This function returns an array of sitemap IDs from WordPress
52+
export const generateSitemaps = async () => {
53+
return await getSitemapPaths();
54+
};
55+
56+
// This function generates the XML for each sitemap based on the ID
57+
export default async function sitemap( { id }: { id: string } ) {
58+
return await generateSubSitemaps( id );
59+
}
60+
61+
// Always render fresh sitemap content
62+
export const revalidate = 0;
63+
```
64+
65+
### What Each Function Does
66+
67+
- **`getSitemapPaths()`** → Fetches all available sitemap IDs (e.g., `wp-sitemap-posts-post-1`, `wp-sitemap-pages-page-1`) from the WordPress sitemap index.
68+
- **`generateSubSitemaps(id)`** → Generates the XML content for the specified sitemap ID by fetching the sub-sitemap and mapping the URLs.
69+
- **`revalidate = 0`** → Disables static caching to ensure the sitemap is always generated fresh on every request.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { MetadataRoute } from 'next';
2+
import { getSitemapPaths, generateSubSitemaps } from '@snapwp/next';
3+
4+
/**
5+
* Returns Sitemap IDs to be dynamically generated.
6+
*
7+
* https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generating-multiple-sitemaps
8+
*
9+
* @return An array of Sitemap IDs.
10+
*/
11+
export const generateSitemaps = async (): Promise<
12+
Array< { id: string } >
13+
> => {
14+
return await getSitemapPaths();
15+
};
16+
17+
/**
18+
* Generate a sitemap for a specific path.
19+
*
20+
* @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap
21+
*
22+
* @param root0 - The object containing the sitemap ID.
23+
* @param root0.id - The ID of the sitemap to generate.
24+
*
25+
* @return The generated sitemap.
26+
*/
27+
export default async function sitemap( {
28+
id,
29+
}: {
30+
id: string;
31+
} ): Promise< MetadataRoute.Sitemap > {
32+
return await generateSubSitemaps( id );
33+
}
34+
35+
// @todo: This will be replaced with on-demand revalidation using revalidateTag or revalidatePath once webhooks are implemented.
36+
// Keeping revalidation to 0 to render it dynamically always.
37+
export const revalidate = 0;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextResponse } from 'next/server';
2+
import { generateIndexSitemap } from '@snapwp/next';
3+
4+
/**
5+
* Generate a sitemap index for all sitemaps.
6+
*
7+
* @return The generated sitemap index.
8+
*/
9+
export async function GET(): Promise< NextResponse > {
10+
const sitemaps = await generateIndexSitemap();
11+
12+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
13+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
14+
${ sitemaps
15+
.map(
16+
( sitemap ) => `
17+
<sitemap>
18+
<loc>${ sitemap.url }</loc>
19+
</sitemap>
20+
`
21+
)
22+
.join( '' ) }
23+
</sitemapindex>`;
24+
25+
return new NextResponse( xml, {
26+
headers: {
27+
'Content-Type': 'application/xml',
28+
},
29+
} );
30+
}

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/src/config/snapwp-config-manager.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import { Logger } from '@/logger';
44
import { generateGraphqlUrl, isValidUrl } from '@/utils';
55

6-
import type { BlockDefinitions, QueryEngine } from '@snapwp/types';
6+
import type {
7+
BlockDefinitions,
8+
QueryEngine,
9+
SitemapConfig,
10+
} from '@snapwp/types';
711
import type { HTMLReactParserOptions } from 'html-react-parser';
812

913
export interface SnapWPEnv {
@@ -46,6 +50,10 @@ export interface SnapWPConfig {
4650
* html-react-parser overload options
4751
*/
4852
parserOptions?: HTMLReactParserOptions;
53+
/**
54+
* Sitemap configuration.
55+
*/
56+
sitemap?: SitemapConfig;
4957
/**
5058
* Query Engine
5159
*/
@@ -79,6 +87,9 @@ const defaultConfig: Partial< SnapWPEnv & SnapWPConfig > = {
7987
graphqlEndpoint: 'index.php?graphql',
8088
restUrlPrefix: '/wp-json',
8189
uploadsDirectory: '/wp-content/uploads',
90+
sitemap: {
91+
indexUri: '/wp-sitemap.xml',
92+
},
8293
};
8394

8495
/**
@@ -138,6 +149,30 @@ class SnapWPConfigManager {
138149
type: 'object',
139150
required: false,
140151
},
152+
sitemap: {
153+
type: 'object',
154+
required: false,
155+
/**
156+
* Validate the sitemap configuration.
157+
*
158+
* @param {SitemapConfig} value The sitemap configuration to validate.
159+
*
160+
* @throws {Error} If the value is invalid.
161+
*/
162+
validate( value ) {
163+
if ( value && typeof value !== 'object' ) {
164+
throw new Error( '`sitemap` should be an object.' );
165+
}
166+
167+
if (
168+
value &&
169+
value.indexUri &&
170+
typeof value.indexUri !== 'string'
171+
) {
172+
throw new Error( '`sitemap.indexUri` should be a string.' );
173+
}
174+
},
175+
},
141176
query: {
142177
type: 'object',
143178
required: true,
@@ -254,7 +289,10 @@ class SnapWPConfigManager {
254289
( key: keyof T ) => {
255290
if ( cfg[ key ] === undefined ) {
256291
delete cfg[ key ];
257-
} else if (
292+
return;
293+
}
294+
295+
if (
258296
// @todo this should probably be moved into the schema as a sanitize callback.
259297
( key === 'wpHomeUrl' ||
260298
key === 'frontendUrl' ||
@@ -266,6 +304,15 @@ class SnapWPConfigManager {
266304
cfg[ key ] = ( value.endsWith( '/' )
267305
? value.slice( 0, -1 )
268306
: value ) as unknown as T[ keyof T ];
307+
308+
return;
309+
}
310+
311+
if ( key === 'sitemap' ) {
312+
cfg[ key ] = {
313+
...defaultConfig.sitemap,
314+
...( cfg[ key ] as SitemapConfig ),
315+
} as unknown as T[ keyof T ];
269316
}
270317
}
271318
);

packages/core/src/config/tests/snapwp-config-manager.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getConfig,
44
getGraphqlUrl,
55
setConfig,
6+
type SnapWPConfig,
67
type SnapWPEnv,
78
} from '@/config/snapwp-config-manager';
89
import { Logger } from '@/logger';
@@ -29,12 +30,15 @@ describe( 'SnapWPConfigManager functions', () => {
2930
restUrlPrefix: '/env-wp-json',
3031
};
3132

32-
const defaultConfig: Partial< SnapWPEnv > = {
33+
const defaultConfig: Partial< SnapWPEnv & SnapWPConfig > = {
3334
corsProxyPrefix:
3435
process.env.NODE_ENV === 'development' ? '/proxy' : undefined,
3536
graphqlEndpoint: 'index.php?graphql',
3637
uploadsDirectory: '/wp-content/uploads',
3738
restUrlPrefix: '/wp-json',
39+
sitemap: {
40+
indexUri: '/wp-sitemap.xml',
41+
},
3842
};
3943

4044
beforeEach( () => {

packages/next/jest.setup.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
global.__snapWPConfig = {};
2+
3+
global.__envConfig = {
4+
frontendUrl: 'https://example.com',
5+
wpHomeUrl: 'https://wp.example.com',
6+
};
7+
8+
process.env.NEXT_PUBLIC_FRONTEND_URL = global.__envConfig.frontendUrl;
9+
process.env.NEXT_PUBLIC_WP_HOME_URL = global.__envConfig.wpHomeUrl;

packages/next/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"html-react-parser": "^5.1.12",
5757
"modify-source-webpack-plugin": "^4.1.0",
5858
"sanitize-html": "^2.15.0",
59-
"zod": "^3.24.2"
59+
"zod": "^3.24.2",
60+
"fast-xml-parser": "^5.2.1"
6061
},
6162
"devDependencies": {
6263
"@snapwp/types": "*",

0 commit comments

Comments
 (0)