Skip to content

Commit 9c8bbfb

Browse files
committed
Move sitemap client into a package
1 parent 5ab180f commit 9c8bbfb

File tree

11 files changed

+220
-73
lines changed

11 files changed

+220
-73
lines changed

core/app/sitemap.xml/route.ts

Lines changed: 6 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,83 +3,16 @@
33
* Proxy to the existing BigCommerce sitemap index on the canonical URL
44
*/
55

6+
import { handleSitemapRoute } from '@bigcommerce/catalyst-sitemap-client';
7+
68
import { getChannelIdFromLocale } from '~/channels.config';
79
import { client } from '~/client';
810
import { defaultLocale } from '~/i18n/locales';
911

1012
export const GET = async (request: Request) => {
11-
const url = new URL(request.url);
12-
const incomingHost = request.headers.get('host') ?? url.host;
13-
const incomingProto = request.headers.get('x-forwarded-proto') ?? url.protocol.replace(':', '');
14-
15-
const type = url.searchParams.get('type');
16-
const page = url.searchParams.get('page');
17-
18-
// If a specific sitemap within the index is requested, require both params
19-
if (type !== null || page !== null) {
20-
if (!type || !page) {
21-
return new Response('Both "type" and "page" query params are required', {
22-
status: 400,
23-
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
24-
});
25-
}
26-
27-
const upstream = await client.fetchSitemapResponse(
28-
{ type, page },
29-
getChannelIdFromLocale(defaultLocale),
30-
);
31-
32-
// Pass-through upstream status/body but enforce XML content-type
33-
const body = await upstream.text();
34-
35-
return new Response(body, {
36-
status: upstream.status,
37-
statusText: upstream.statusText,
38-
headers: { 'Content-Type': 'application/xml' },
39-
});
40-
}
41-
42-
// Otherwise, return the sitemap index with normalized internal links
43-
const sitemapIndex = await client.fetchSitemapIndex(getChannelIdFromLocale(defaultLocale));
44-
45-
const rewritten = sitemapIndex.replace(
46-
/<loc>([^<]+)<\/loc>/g,
47-
(match: string, locUrlStr: string) => {
48-
try {
49-
// Decode XML entities for '&' so URL parsing works
50-
const decoded: string = locUrlStr.replace(/&amp;/g, '&');
51-
const original = new URL(decoded);
52-
53-
if (!original.pathname.endsWith('/xmlsitemap.php')) {
54-
return match;
55-
}
56-
57-
const normalized = new URL(`${incomingProto}://${incomingHost}/sitemap.xml`);
58-
59-
const t = original.searchParams.get('type');
60-
const p = original.searchParams.get('page');
61-
62-
// Only rewrite entries that include both type and page; otherwise leave untouched
63-
if (!t || !p) {
64-
return match;
65-
}
66-
67-
normalized.searchParams.set('type', t);
68-
normalized.searchParams.set('page', p);
69-
70-
// Re-encode '&' for XML output
71-
const normalizedXml: string = normalized.toString().replace(/&/g, '&amp;');
72-
73-
return `<loc>${normalizedXml}</loc>`;
74-
} catch {
75-
return match;
76-
}
77-
},
78-
);
79-
80-
return new Response(rewritten, {
81-
headers: {
82-
'Content-Type': 'application/xml',
83-
},
13+
return handleSitemapRoute(request, {
14+
getSitemapIndex: () => client.fetchSitemapIndex(getChannelIdFromLocale(defaultLocale)),
15+
fetchSitemapResponse: ({ type, page }) =>
16+
client.fetchSitemapResponse({ type, page }, getChannelIdFromLocale(defaultLocale)),
8417
});
8518
};

core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
},
1515
"dependencies": {
1616
"@bigcommerce/catalyst-client": "workspace:^",
17+
"@bigcommerce/catalyst-sitemap-client": "workspace:^",
1718
"@conform-to/react": "^1.6.1",
1819
"@conform-to/zod": "^1.6.1",
1920
"@icons-pack/react-simple-icons": "^11.2.0",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
3+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = {
2+
root: true,
3+
extends: ['@bigcommerce/eslint-config-catalyst/base'],
4+
parserOptions: {
5+
project: ['./tsconfig.eslint.json'],
6+
},
7+
};
8+
9+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## @bigcommerce/catalyst-sitemap-client
2+
3+
A small helper to handle Catalyst's `/sitemap.xml` route with:
4+
- Sitemap index rewriting to the incoming host and normalized path `/sitemap.xml`, only when both `type` and `page` are present
5+
- Proxying of specific sitemap pages when `type` and `page` are provided on the query string
6+
7+
### Usage
8+
9+
```ts
10+
// core/app/sitemap.xml/route.ts
11+
import { handleSitemapRoute } from '@bigcommerce/catalyst-sitemap-client';
12+
import { client } from '~/client';
13+
import { defaultLocale } from '~/i18n/locales';
14+
import { getChannelIdFromLocale } from '~/channels.config';
15+
16+
export const GET = async (request: Request) =>
17+
handleSitemapRoute(request, {
18+
getSitemapIndex: () => client.fetchSitemapIndex(getChannelIdFromLocale(defaultLocale)),
19+
fetchSitemapResponse: ({ type, page }) =>
20+
client.fetchSitemapResponse({ type, page }, getChannelIdFromLocale(defaultLocale)),
21+
});
22+
```
23+
24+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@bigcommerce/catalyst-sitemap-client",
3+
"version": "1.0.0",
4+
"private": true,
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"files": [
8+
"dist"
9+
],
10+
"scripts": {
11+
"build": "tsup",
12+
"lint": "eslint . --ext .ts,.js --max-warnings 0",
13+
"typecheck": "tsc --noEmit"
14+
},
15+
"devDependencies": {
16+
"tsup": "^8.5.0",
17+
"typescript": "^5.8.3",
18+
"eslint": "^8.57.1",
19+
"@bigcommerce/eslint-config-catalyst": "workspace:^"
20+
}
21+
}
22+
23+
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
export interface FetchSitemapParams {
2+
type?: string | null;
3+
page?: string | number | null;
4+
}
5+
6+
export interface HandleSitemapRouteOptions {
7+
getSitemapIndex: () => Promise<string>;
8+
fetchSitemapResponse: (params: FetchSitemapParams) => Promise<Response>;
9+
}
10+
11+
/**
12+
* Handles GET /sitemap.xml with two behaviors:
13+
* - Without query params: returns the sitemap index with <loc> links rewritten to the incoming host and
14+
* normalized to /sitemap.xml, only when both type and page exist on the original link.
15+
* - With query params: requires both type and page, proxies upstream response as-is (status/body),
16+
* ignoring any other params.
17+
*/
18+
/**
19+
* Handles the Catalyst sitemap route.
20+
*
21+
* @param {Request} request Incoming Next.js Request for `/sitemap.xml`.
22+
* @param {any} options Helpers to fetch the upstream sitemap resources.
23+
* @returns {Promise<Response>} Response suitable for returning from the route handler.
24+
*/
25+
export async function handleSitemapRoute(
26+
request: Request,
27+
options: HandleSitemapRouteOptions,
28+
): Promise<Response> {
29+
const { getSitemapIndex, fetchSitemapResponse } = options;
30+
const requestUrl = new URL(request.url);
31+
const incomingHost: string = request.headers.get('host') ?? requestUrl.host;
32+
const incomingProtoHeader = request.headers.get('x-forwarded-proto');
33+
const incomingProtocol: string = incomingProtoHeader ?? requestUrl.protocol.replace(':', '');
34+
35+
const typeParam = requestUrl.searchParams.get('type');
36+
const pageParam = requestUrl.searchParams.get('page');
37+
38+
// If any sitemap page is requested, require both well-known params
39+
if (typeParam !== null || pageParam !== null) {
40+
if (!typeParam || !pageParam) {
41+
return new Response('Both "type" and "page" query params are required', {
42+
status: 400,
43+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
44+
});
45+
}
46+
47+
const upstream = await fetchSitemapResponse({ type: typeParam, page: pageParam });
48+
const body = await upstream.text();
49+
50+
return new Response(body, {
51+
status: upstream.status,
52+
statusText: upstream.statusText,
53+
headers: { 'Content-Type': 'application/xml' },
54+
});
55+
}
56+
57+
const sitemapIndexXml = await getSitemapIndex();
58+
59+
// Rewrite <loc> links to use incoming host and normalized path, only when both type & page exist
60+
const rewrittenXml = sitemapIndexXml.replace(
61+
/<loc>([^<]+)<\/loc>/g,
62+
(match: string, locUrlStr: string) => {
63+
try {
64+
const decoded: string = locUrlStr.replace(/&amp;/g, '&');
65+
const original = new URL(decoded);
66+
67+
if (!original.pathname.endsWith('/xmlsitemap.php')) {
68+
return match;
69+
}
70+
71+
const normalized = new URL(`${incomingProtocol}://${incomingHost}/sitemap.xml`);
72+
73+
const t = original.searchParams.get('type');
74+
const p = original.searchParams.get('page');
75+
76+
if (!t || !p) {
77+
return match;
78+
}
79+
80+
normalized.searchParams.set('type', t);
81+
normalized.searchParams.set('page', p);
82+
83+
const normalizedXml: string = normalized.toString().replace(/&/g, '&amp;');
84+
85+
return `<loc>${normalizedXml}</loc>`;
86+
} catch {
87+
return match;
88+
}
89+
},
90+
);
91+
92+
return new Response(rewrittenXml, {
93+
headers: {
94+
'Content-Type': 'application/xml',
95+
},
96+
});
97+
}
98+
99+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": [
4+
"src/**/*",
5+
"tsup.config.ts"
6+
]
7+
}
8+
9+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "CommonJS",
5+
"moduleResolution": "Node",
6+
"strict": true,
7+
"esModuleInterop": true,
8+
"forceConsistentCasingInFileNames": true,
9+
"skipLibCheck": true,
10+
"declaration": true,
11+
"outDir": "dist"
12+
},
13+
"include": ["src/**/*"]
14+
}
15+
16+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig, Options } from 'tsup';
2+
3+
export default defineConfig((options: Options) => ({
4+
entry: ['src/index.ts'],
5+
format: ['cjs'],
6+
dts: true,
7+
clean: !options.watch,
8+
...options,
9+
}));
10+
11+

0 commit comments

Comments
 (0)