@reactionary/feeds 0.0.1
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 +257 -0
- package/bin/reactionary-feeds +2 -0
- package/cli.cjs +170941 -0
- package/cli.js +340 -0
- package/documentation/basic-product-feeds.config.mjs +32 -0
- package/documentation/multilingual-feeds.config.mjs +59 -0
- package/documentation/nextjs-route-example.mjs +31 -0
- package/documentation/sitemaps.config.mjs +77 -0
- package/index.js +13 -0
- package/lib/default-registry.js +18 -0
- package/lib/env-client-builder.js +244 -0
- package/lib/feed-generator.js +395 -0
- package/lib/feed-server.js +305 -0
- package/lib/feed-transformer.js +21 -0
- package/lib/feed-types.js +45 -0
- package/lib/load-cwd-env.js +16 -0
- package/lib/sitemap-generator.js +173 -0
- package/lib/sitemap-xml.js +32 -0
- package/lib/transformers/acp-product-feed.transformer.js +128 -0
- package/lib/transformers/google-merchant-feed.transformer.js +60 -0
- package/lib/transformers/pricerunner-feed.transformer.js +60 -0
- package/lib/transformers/shared.js +25 -0
- package/lib/transformers/sitemap-feed.transformer.js +26 -0
- package/package.json +32 -0
- package/src/cli.d.ts +2 -0
- package/src/index.d.ts +13 -0
- package/src/lib/default-registry.d.ts +2 -0
- package/src/lib/env-client-builder.d.ts +18 -0
- package/src/lib/feed-generator.d.ts +17 -0
- package/src/lib/feed-server.d.ts +28 -0
- package/src/lib/feed-transformer.d.ts +30 -0
- package/src/lib/feed-types.d.ts +185 -0
- package/src/lib/load-cwd-env.d.ts +4 -0
- package/src/lib/sitemap-generator.d.ts +20 -0
- package/src/lib/sitemap-xml.d.ts +3 -0
- package/src/lib/transformers/acp-product-feed.transformer.d.ts +4 -0
- package/src/lib/transformers/google-merchant-feed.transformer.d.ts +2 -0
- package/src/lib/transformers/pricerunner-feed.transformer.d.ts +2 -0
- package/src/lib/transformers/shared.d.ts +6 -0
- package/src/lib/transformers/sitemap-feed.transformer.d.ts +2 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createInitialRequestContext
|
|
3
|
+
} from "@reactionary/core";
|
|
4
|
+
import { createDefaultFeedRegistry } from "./default-registry.js";
|
|
5
|
+
import { ReactionaryFeedGenerator } from "./feed-generator.js";
|
|
6
|
+
import { ReactionarySitemapGenerator } from "./sitemap-generator.js";
|
|
7
|
+
import {
|
|
8
|
+
toSitemapIndexXml,
|
|
9
|
+
toSitemapXml
|
|
10
|
+
} from "./sitemap-xml.js";
|
|
11
|
+
class ReactionaryFeedServer {
|
|
12
|
+
constructor(clientFactory, options) {
|
|
13
|
+
this.clientFactory = clientFactory;
|
|
14
|
+
this.options = options;
|
|
15
|
+
this.registry = options.registry ?? createDefaultFeedRegistry();
|
|
16
|
+
}
|
|
17
|
+
clientFactory;
|
|
18
|
+
options;
|
|
19
|
+
registry;
|
|
20
|
+
async fetch(request) {
|
|
21
|
+
try {
|
|
22
|
+
return await this.handleRequest(request);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return jsonResponse({
|
|
25
|
+
error: error instanceof Error ? error.message : String(error)
|
|
26
|
+
}, { status: 500 });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
getHandler() {
|
|
30
|
+
return {
|
|
31
|
+
fetch: (request) => this.fetch(request),
|
|
32
|
+
close: () => this.close()
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
toNodeHandler() {
|
|
36
|
+
return async (request, response) => {
|
|
37
|
+
const webResponse = await this.fetch(await toWebRequest(request));
|
|
38
|
+
await sendWebResponse(response, webResponse);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
close() {
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
}
|
|
44
|
+
async handleRequest(request) {
|
|
45
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
46
|
+
return jsonResponse({
|
|
47
|
+
error: `Unsupported method: ${request.method}`
|
|
48
|
+
}, {
|
|
49
|
+
status: 405,
|
|
50
|
+
headers: { allow: "GET, HEAD" }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const route = parseFeedRoute(request, this.options.basePath);
|
|
54
|
+
const sitemapRoute = parseSitemapRoute(request, this.options.sitemaps);
|
|
55
|
+
if (sitemapRoute.kind === "index") {
|
|
56
|
+
return this.generateSitemapIndexResponse(request);
|
|
57
|
+
}
|
|
58
|
+
if (sitemapRoute.kind === "source") {
|
|
59
|
+
return this.generateSitemapResponse(request, sitemapRoute.sourceId);
|
|
60
|
+
}
|
|
61
|
+
if (route.kind === "feeds") {
|
|
62
|
+
return jsonResponse({
|
|
63
|
+
feeds: Object.keys(this.options.feeds)
|
|
64
|
+
}, { omitBody: request.method === "HEAD" });
|
|
65
|
+
}
|
|
66
|
+
if (route.kind === "transformers") {
|
|
67
|
+
return jsonResponse({
|
|
68
|
+
transformers: this.registry.list()
|
|
69
|
+
}, { omitBody: request.method === "HEAD" });
|
|
70
|
+
}
|
|
71
|
+
if (route.kind !== "output") {
|
|
72
|
+
return jsonResponse({
|
|
73
|
+
error: "Not found"
|
|
74
|
+
}, { status: 404, omitBody: request.method === "HEAD" });
|
|
75
|
+
}
|
|
76
|
+
const feed = this.options.feeds[route.feedId];
|
|
77
|
+
const transformer = this.registry.get(route.transformerId);
|
|
78
|
+
if (!feed) {
|
|
79
|
+
return jsonResponse({
|
|
80
|
+
error: `Feed not found: ${route.feedId}`
|
|
81
|
+
}, { status: 404 });
|
|
82
|
+
}
|
|
83
|
+
if (!transformer) {
|
|
84
|
+
return jsonResponse({
|
|
85
|
+
error: `Feed transformer not found: ${route.transformerId}`
|
|
86
|
+
}, { status: 404 });
|
|
87
|
+
}
|
|
88
|
+
return this.generateFeedResponse(
|
|
89
|
+
request,
|
|
90
|
+
route.feedId,
|
|
91
|
+
feed,
|
|
92
|
+
transformer
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
generateFeedResponse(request, feedId, feed, transformer) {
|
|
96
|
+
const requestContext = createInitialRequestContext();
|
|
97
|
+
requestContext.languageContext = feed.languageContext;
|
|
98
|
+
const client = this.clientFactory(requestContext);
|
|
99
|
+
const generator = new ReactionaryFeedGenerator(client, {
|
|
100
|
+
defaultFulfillmentCenterKeys: this.options.defaultFulfillmentCenterKeys,
|
|
101
|
+
productConcurrency: this.options.productConcurrency
|
|
102
|
+
});
|
|
103
|
+
const output = transformer.transform(
|
|
104
|
+
generator.products(feed, requestContext),
|
|
105
|
+
{
|
|
106
|
+
feedId,
|
|
107
|
+
feed,
|
|
108
|
+
options: transformer.defaultOptions
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
return new Response(
|
|
112
|
+
request.method === "HEAD" ? null : iterableToStream(output),
|
|
113
|
+
{
|
|
114
|
+
headers: {
|
|
115
|
+
"content-type": transformer.output.contentType,
|
|
116
|
+
"content-disposition": `attachment; filename="${feedId}.${transformer.output.fileExtension}"`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
async generateSitemapIndexResponse(request) {
|
|
122
|
+
const sitemaps = this.options.sitemaps;
|
|
123
|
+
if (!sitemaps) {
|
|
124
|
+
return jsonResponse({
|
|
125
|
+
error: "Sitemaps are not configured."
|
|
126
|
+
}, { status: 404, omitBody: request.method === "HEAD" });
|
|
127
|
+
}
|
|
128
|
+
const baseUrl = trimTrailingSlash(sitemaps.baseUrl);
|
|
129
|
+
const sourceIds = getIncludedSitemapSourceIds(sitemaps);
|
|
130
|
+
const xml = await toSitemapIndexXml(
|
|
131
|
+
sourceIds.map((sourceId) => `${baseUrl}/sitemaps/${encodeURIComponent(sourceId)}.xml`)
|
|
132
|
+
);
|
|
133
|
+
return xmlResponse(xml, {
|
|
134
|
+
omitBody: request.method === "HEAD"
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async generateSitemapResponse(request, sourceId) {
|
|
138
|
+
const sitemaps = this.options.sitemaps;
|
|
139
|
+
if (!sitemaps) {
|
|
140
|
+
return jsonResponse({
|
|
141
|
+
error: "Sitemaps are not configured."
|
|
142
|
+
}, { status: 404, omitBody: request.method === "HEAD" });
|
|
143
|
+
}
|
|
144
|
+
if (!getIncludedSitemapSourceIds(sitemaps).includes(sourceId)) {
|
|
145
|
+
return jsonResponse({
|
|
146
|
+
error: `Sitemap source not found: ${sourceId}`
|
|
147
|
+
}, { status: 404, omitBody: request.method === "HEAD" });
|
|
148
|
+
}
|
|
149
|
+
const source = sitemaps.sources[sourceId];
|
|
150
|
+
if (!source) {
|
|
151
|
+
return jsonResponse({
|
|
152
|
+
error: `Sitemap source not found: ${sourceId}`
|
|
153
|
+
}, { status: 404, omitBody: request.method === "HEAD" });
|
|
154
|
+
}
|
|
155
|
+
const requestContext = createInitialRequestContext();
|
|
156
|
+
const languageContext = source.type === "products" ? this.options.feeds[source.feed]?.languageContext : source.languageContext;
|
|
157
|
+
if (languageContext) {
|
|
158
|
+
requestContext.languageContext = languageContext;
|
|
159
|
+
}
|
|
160
|
+
const client = this.clientFactory(requestContext);
|
|
161
|
+
const generator = new ReactionarySitemapGenerator(
|
|
162
|
+
client,
|
|
163
|
+
this.options.feeds,
|
|
164
|
+
{
|
|
165
|
+
defaultFulfillmentCenterKeys: this.options.defaultFulfillmentCenterKeys,
|
|
166
|
+
productConcurrency: this.options.productConcurrency
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
const xml = await toSitemapXml(generator.entries(source, requestContext));
|
|
170
|
+
return xmlResponse(xml, {
|
|
171
|
+
headers: {
|
|
172
|
+
"content-disposition": `attachment; filename="${sourceId}.xml"`
|
|
173
|
+
},
|
|
174
|
+
omitBody: request.method === "HEAD"
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function parseSitemapRoute(request, sitemaps) {
|
|
179
|
+
if (!sitemaps) {
|
|
180
|
+
return { kind: "not-found" };
|
|
181
|
+
}
|
|
182
|
+
const pathname = new URL(request.url).pathname;
|
|
183
|
+
if (pathname === "/sitemaps.xml") {
|
|
184
|
+
return { kind: "index" };
|
|
185
|
+
}
|
|
186
|
+
const match = /^\/sitemaps\/([^/]+)\.xml$/.exec(pathname);
|
|
187
|
+
if (match) {
|
|
188
|
+
return {
|
|
189
|
+
kind: "source",
|
|
190
|
+
sourceId: decodeURIComponent(match[1] ?? "")
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { kind: "not-found" };
|
|
194
|
+
}
|
|
195
|
+
function parseFeedRoute(request, basePath = "/feeds") {
|
|
196
|
+
const pathname = getProtocolPathname(request, basePath);
|
|
197
|
+
if (pathname === "/" || pathname === "") {
|
|
198
|
+
return { kind: "feeds" };
|
|
199
|
+
}
|
|
200
|
+
if (pathname === "/transformers") {
|
|
201
|
+
return { kind: "transformers" };
|
|
202
|
+
}
|
|
203
|
+
const match = /^\/([^/]+)\/outputs\/([^/]+)$/.exec(pathname);
|
|
204
|
+
if (match) {
|
|
205
|
+
return {
|
|
206
|
+
kind: "output",
|
|
207
|
+
feedId: decodeURIComponent(match[1] ?? ""),
|
|
208
|
+
transformerId: decodeURIComponent(match[2] ?? "")
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return { kind: "not-found" };
|
|
212
|
+
}
|
|
213
|
+
function getProtocolPathname(request, basePath) {
|
|
214
|
+
const pathname = new URL(request.url).pathname;
|
|
215
|
+
if (pathname === basePath) {
|
|
216
|
+
return "/";
|
|
217
|
+
}
|
|
218
|
+
if (pathname.startsWith(`${basePath}/`)) {
|
|
219
|
+
return pathname.slice(basePath.length);
|
|
220
|
+
}
|
|
221
|
+
return pathname;
|
|
222
|
+
}
|
|
223
|
+
function iterableToStream(iterable) {
|
|
224
|
+
const encoder = new TextEncoder();
|
|
225
|
+
const iterator = iterable[Symbol.asyncIterator]();
|
|
226
|
+
return new ReadableStream({
|
|
227
|
+
async pull(controller) {
|
|
228
|
+
const next = await iterator.next();
|
|
229
|
+
if (next.done) {
|
|
230
|
+
controller.close();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
controller.enqueue(
|
|
234
|
+
typeof next.value === "string" ? encoder.encode(next.value) : next.value
|
|
235
|
+
);
|
|
236
|
+
},
|
|
237
|
+
async cancel() {
|
|
238
|
+
await iterator.return?.();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function jsonResponse(body, options = {}) {
|
|
243
|
+
return new Response(options.omitBody ? null : JSON.stringify(body), {
|
|
244
|
+
status: options.status ?? 200,
|
|
245
|
+
headers: {
|
|
246
|
+
"content-type": "application/json; charset=utf-8",
|
|
247
|
+
...options.headers
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function xmlResponse(body, options = {}) {
|
|
252
|
+
return new Response(options.omitBody ? null : body, {
|
|
253
|
+
headers: {
|
|
254
|
+
"content-type": "application/xml; charset=utf-8",
|
|
255
|
+
...options.headers
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function getIncludedSitemapSourceIds(sitemaps) {
|
|
260
|
+
return sitemaps.include ?? Object.keys(sitemaps.sources);
|
|
261
|
+
}
|
|
262
|
+
function trimTrailingSlash(value) {
|
|
263
|
+
return value.replace(/\/+$/, "");
|
|
264
|
+
}
|
|
265
|
+
async function toWebRequest(request) {
|
|
266
|
+
const headers = toWebHeaders(request.headers);
|
|
267
|
+
const url = new URL(
|
|
268
|
+
request.url ?? "/",
|
|
269
|
+
`http://${request.headers.host ?? "localhost"}`
|
|
270
|
+
);
|
|
271
|
+
return new Request(url, {
|
|
272
|
+
method: request.method,
|
|
273
|
+
headers
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function toWebHeaders(headers) {
|
|
277
|
+
const webHeaders = new Headers();
|
|
278
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
279
|
+
if (value === void 0) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (Array.isArray(value)) {
|
|
283
|
+
for (const entry of value) {
|
|
284
|
+
webHeaders.append(key, entry);
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
webHeaders.set(key, value);
|
|
289
|
+
}
|
|
290
|
+
return webHeaders;
|
|
291
|
+
}
|
|
292
|
+
async function sendWebResponse(response, webResponse) {
|
|
293
|
+
response.writeHead(
|
|
294
|
+
webResponse.status,
|
|
295
|
+
Object.fromEntries(webResponse.headers.entries())
|
|
296
|
+
);
|
|
297
|
+
if (webResponse.body) {
|
|
298
|
+
response.end(Buffer.from(await webResponse.arrayBuffer()));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
response.end();
|
|
302
|
+
}
|
|
303
|
+
export {
|
|
304
|
+
ReactionaryFeedServer
|
|
305
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class ReactionaryFeedTransformerRegistry {
|
|
2
|
+
transformers = /* @__PURE__ */ new Map();
|
|
3
|
+
register(transformer) {
|
|
4
|
+
this.transformers.set(transformer.id, transformer);
|
|
5
|
+
}
|
|
6
|
+
get(id) {
|
|
7
|
+
return this.transformers.get(id);
|
|
8
|
+
}
|
|
9
|
+
list() {
|
|
10
|
+
return [...this.transformers.values()].map((transformer) => ({
|
|
11
|
+
id: transformer.id,
|
|
12
|
+
title: transformer.title,
|
|
13
|
+
description: transformer.description,
|
|
14
|
+
contentType: transformer.output.contentType,
|
|
15
|
+
fileExtension: transformer.output.fileExtension
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export {
|
|
20
|
+
ReactionaryFeedTransformerRegistry
|
|
21
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
function assertReactionaryFeedClient(client) {
|
|
2
|
+
const missing = getMissingFeedClientOperations(client);
|
|
3
|
+
if (missing.length > 0) {
|
|
4
|
+
throw new Error(
|
|
5
|
+
`Reactionary feed generator cannot initialize because the client is missing required operations: ${missing.join(", ")}`
|
|
6
|
+
);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function getMissingFeedClientOperations(client) {
|
|
10
|
+
return [
|
|
11
|
+
["productSearch.queryByTerm", client.productSearch?.queryByTerm],
|
|
12
|
+
["product.getBySKU", client.product?.getBySKU],
|
|
13
|
+
["price.getCustomerPrice", client.price?.getCustomerPrice],
|
|
14
|
+
["price.getListPrice", client.price?.getListPrice],
|
|
15
|
+
["inventory.getBySKU", client.inventory?.getBySKU]
|
|
16
|
+
].filter(([, operation]) => typeof operation !== "function").map(([name]) => name);
|
|
17
|
+
}
|
|
18
|
+
function getMissingSitemapClientOperations(client, source) {
|
|
19
|
+
if (source.type === "products") {
|
|
20
|
+
return getMissingFeedClientOperations(client);
|
|
21
|
+
}
|
|
22
|
+
if (source.type === "categories") {
|
|
23
|
+
return [
|
|
24
|
+
["category.findTopCategories", client.category?.findTopCategories],
|
|
25
|
+
["category.findChildCategories", client.category?.findChildCategories]
|
|
26
|
+
].filter(([, operation]) => typeof operation !== "function").map(([name]) => name);
|
|
27
|
+
}
|
|
28
|
+
return [
|
|
29
|
+
["store.queryByProximity", client.store?.queryByProximity]
|
|
30
|
+
].filter(([, operation]) => typeof operation !== "function").map(([name]) => name);
|
|
31
|
+
}
|
|
32
|
+
function assertReactionarySitemapClient(client, source) {
|
|
33
|
+
const missing = getMissingSitemapClientOperations(client, source);
|
|
34
|
+
if (missing.length > 0) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Reactionary sitemap source cannot initialize because the client is missing required operations: ${missing.join(", ")}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export {
|
|
41
|
+
assertReactionaryFeedClient,
|
|
42
|
+
assertReactionarySitemapClient,
|
|
43
|
+
getMissingFeedClientOperations,
|
|
44
|
+
getMissingSitemapClientOperations
|
|
45
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { config } from "dotenv";
|
|
2
|
+
function loadCwdEnv(options = {}) {
|
|
3
|
+
const env = options.env ?? process.env;
|
|
4
|
+
const result = env["DOTENV_CONFIG_PATH"] ? config({ path: env["DOTENV_CONFIG_PATH"] }) : config();
|
|
5
|
+
if (!result.error) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const errorCode = result.error.code;
|
|
9
|
+
if (errorCode === "ENOENT" && env["DOTENV_CONFIG_PATH"] === void 0) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
throw result.error;
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
loadCwdEnv
|
|
16
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ReactionaryFeedGenerator } from "./feed-generator.js";
|
|
2
|
+
import {
|
|
3
|
+
assertReactionarySitemapClient
|
|
4
|
+
} from "./feed-types.js";
|
|
5
|
+
class ReactionarySitemapGenerator {
|
|
6
|
+
constructor(client, feeds, options = {}) {
|
|
7
|
+
this.client = client;
|
|
8
|
+
this.feeds = feeds;
|
|
9
|
+
this.options = options;
|
|
10
|
+
}
|
|
11
|
+
client;
|
|
12
|
+
feeds;
|
|
13
|
+
options;
|
|
14
|
+
async *entries(source, requestContext) {
|
|
15
|
+
assertReactionarySitemapClient(this.client, source);
|
|
16
|
+
if (source.type === "products") {
|
|
17
|
+
yield* this.productEntries(source.feed, source, requestContext);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
requestContext.languageContext = source.languageContext;
|
|
21
|
+
if (source.type === "categories") {
|
|
22
|
+
yield* this.categoryEntries(source);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
yield* this.storeEntries(source);
|
|
26
|
+
}
|
|
27
|
+
async *productEntries(feedId, source, requestContext) {
|
|
28
|
+
const feed = this.feeds[feedId];
|
|
29
|
+
if (!feed) {
|
|
30
|
+
throw new Error(`Product sitemap feed not found: ${feedId}`);
|
|
31
|
+
}
|
|
32
|
+
const generator = new ReactionaryFeedGenerator(this.client, this.options);
|
|
33
|
+
for await (const product of generator.products(feed, requestContext)) {
|
|
34
|
+
if (!product.url) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
yield {
|
|
38
|
+
url: product.url,
|
|
39
|
+
changefreq: source.changefreq,
|
|
40
|
+
priority: source.priority
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async *categoryEntries(source) {
|
|
45
|
+
const client = this.client;
|
|
46
|
+
if (!client.category) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const topCategories = this.iterCategoryPageResults(
|
|
50
|
+
(pageNumber, pageSize) => client.category?.findTopCategories({
|
|
51
|
+
paginationOptions: {
|
|
52
|
+
pageNumber,
|
|
53
|
+
pageSize
|
|
54
|
+
}
|
|
55
|
+
}) ?? Promise.resolve({
|
|
56
|
+
success: true,
|
|
57
|
+
value: emptyCategoryPage(pageNumber, pageSize)
|
|
58
|
+
}),
|
|
59
|
+
source
|
|
60
|
+
);
|
|
61
|
+
for await (const category of topCategories) {
|
|
62
|
+
yield* this.categoryWithChildrenEntries(category, source, 1);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async *categoryWithChildrenEntries(category, source, depth) {
|
|
66
|
+
const url = toTemplatedUrl(source.urlTemplate, source.languageContext, {
|
|
67
|
+
id: category.identifier.key,
|
|
68
|
+
slug: category.slug || category.identifier.key
|
|
69
|
+
});
|
|
70
|
+
if (url) {
|
|
71
|
+
yield {
|
|
72
|
+
url,
|
|
73
|
+
changefreq: source.changefreq,
|
|
74
|
+
priority: source.priority
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (source.includeChildren === false || depth >= (source.maxDepth ?? 10)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const client = this.client;
|
|
81
|
+
if (!client.category) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const childCategories = this.iterCategoryPageResults(
|
|
85
|
+
(pageNumber, pageSize) => client.category?.findChildCategories({
|
|
86
|
+
parentId: category.identifier,
|
|
87
|
+
paginationOptions: {
|
|
88
|
+
pageNumber,
|
|
89
|
+
pageSize
|
|
90
|
+
}
|
|
91
|
+
}) ?? Promise.resolve({
|
|
92
|
+
success: true,
|
|
93
|
+
value: emptyCategoryPage(pageNumber, pageSize)
|
|
94
|
+
}),
|
|
95
|
+
source
|
|
96
|
+
);
|
|
97
|
+
for await (const child of childCategories) {
|
|
98
|
+
yield* this.categoryWithChildrenEntries(child, source, depth + 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async *iterCategoryPageResults(loadPage, source) {
|
|
102
|
+
const pageSize = source.pageSize ?? 50;
|
|
103
|
+
const maxPages = source.maxPagesPerLevel ?? 100;
|
|
104
|
+
let pageNumber = 1;
|
|
105
|
+
while (pageNumber <= maxPages) {
|
|
106
|
+
const result = await loadPage(pageNumber, pageSize);
|
|
107
|
+
if (!result.success) {
|
|
108
|
+
throw new Error(JSON.stringify(result.error));
|
|
109
|
+
}
|
|
110
|
+
for (const category of result.value.items) {
|
|
111
|
+
yield category;
|
|
112
|
+
}
|
|
113
|
+
if (pageNumber >= result.value.totalPages || result.value.items.length === 0) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
pageNumber += 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async *storeEntries(source) {
|
|
120
|
+
const result = await this.client.store?.queryByProximity(source.proximity);
|
|
121
|
+
if (!result) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!result.success) {
|
|
125
|
+
throw new Error(JSON.stringify(result.error));
|
|
126
|
+
}
|
|
127
|
+
for (const store of result.value) {
|
|
128
|
+
const url = toTemplatedUrl(source.urlTemplate, source.languageContext, {
|
|
129
|
+
id: store.identifier.key,
|
|
130
|
+
slug: slugifyStore(store)
|
|
131
|
+
});
|
|
132
|
+
if (!url) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
yield {
|
|
136
|
+
url,
|
|
137
|
+
changefreq: source.changefreq,
|
|
138
|
+
priority: source.priority
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function toTemplatedUrl(urlTemplate, languageContext, values) {
|
|
144
|
+
const slug = values.slug ?? values.id;
|
|
145
|
+
if (!urlTemplate) {
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
if (urlTemplate.includes("{lang}") || urlTemplate.includes("{slug}") || urlTemplate.includes("{id}")) {
|
|
149
|
+
return urlTemplate.replaceAll("{lang}", getLanguage(languageContext)).replaceAll("{slug}", encodeURIComponent(slug)).replaceAll("{id}", encodeURIComponent(values.id));
|
|
150
|
+
}
|
|
151
|
+
return new URL(slug, urlTemplate).href;
|
|
152
|
+
}
|
|
153
|
+
function getLanguage(languageContext) {
|
|
154
|
+
const [language] = languageContext.locale.split("-");
|
|
155
|
+
return language.toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
function slugifyStore(store) {
|
|
158
|
+
const slug = store.name.trim().toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "");
|
|
159
|
+
return slug || store.identifier.key;
|
|
160
|
+
}
|
|
161
|
+
function emptyCategoryPage(pageNumber, pageSize) {
|
|
162
|
+
return {
|
|
163
|
+
pageNumber,
|
|
164
|
+
pageSize,
|
|
165
|
+
totalCount: 0,
|
|
166
|
+
totalPages: 0,
|
|
167
|
+
items: []
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
export {
|
|
171
|
+
ReactionarySitemapGenerator,
|
|
172
|
+
toTemplatedUrl
|
|
173
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SitemapIndexStream,
|
|
3
|
+
SitemapStream,
|
|
4
|
+
streamToPromise
|
|
5
|
+
} from "sitemap";
|
|
6
|
+
async function toSitemapXml(entries) {
|
|
7
|
+
const sitemap = new SitemapStream();
|
|
8
|
+
for await (const entry of entries) {
|
|
9
|
+
sitemap.write({
|
|
10
|
+
url: entry.url,
|
|
11
|
+
lastmod: entry.lastmod,
|
|
12
|
+
changefreq: entry.changefreq,
|
|
13
|
+
priority: entry.priority
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
sitemap.end();
|
|
17
|
+
return (await streamToPromise(sitemap)).toString();
|
|
18
|
+
}
|
|
19
|
+
async function toSitemapIndexXml(urls) {
|
|
20
|
+
const sitemapIndex = new SitemapIndexStream();
|
|
21
|
+
for (const url of urls) {
|
|
22
|
+
sitemapIndex.write({
|
|
23
|
+
url
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
sitemapIndex.end();
|
|
27
|
+
return (await streamToPromise(sitemapIndex)).toString();
|
|
28
|
+
}
|
|
29
|
+
export {
|
|
30
|
+
toSitemapIndexXml,
|
|
31
|
+
toSitemapXml
|
|
32
|
+
};
|