-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathsitemap.ts
754 lines (670 loc) · 25.9 KB
/
sitemap.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
export type Changefreq = 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly';
/* eslint-disable perfectionist/sort-object-types */
export type ParamValue = {
values: string[];
lastmod?: string;
priority?: Priority;
changefreq?: Changefreq;
};
/* eslint-disable perfectionist/sort-object-types */
export type ParamValues = Record<string, ParamValue[] | never | string[] | string[][]>;
export type Priority = 0.0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0;
/* eslint-disable perfectionist/sort-object-types */
export type SitemapConfig = {
additionalPaths?: [] | string[];
excludeRoutePatterns?: [] | string[];
headers?: Record<string, string>;
lang?: {
default: string;
alternates: string[];
};
maxPerPage?: number;
origin: string;
page?: string;
/**
* Parameter values for dynamic routes, where the values can be:
* - `string[]`
* - `string[][]`
* - `ParamValueObj[]`
*/
paramValues?: ParamValues;
/**
* Optional. Default changefreq, when not specified within a route's `paramValues` objects.
* Omitting from sitemap config will omit changefreq from all sitemap entries except
* those where you set `changefreq` property with a route's `paramValues` objects.
*/
defaultChangefreq?: Changefreq;
/**
* Optional. Default priority, when not specified within a route's `paramValues` objects.
* Omitting from sitemap config will omit priority from all sitemap entries except
* those where you set `priority` property with a route's `paramValues` objects.
*/
defaultPriority?: Priority;
processPaths?: (paths: PathObj[]) => PathObj[];
sort?: 'alpha' | false;
};
export type LangConfig = {
default: string;
alternates: string[];
};
export type Alternate = {
lang: string;
path: string;
};
export type PathObj = {
path: string;
lastmod?: string; // ISO 8601 datetime
changefreq?: Changefreq;
priority?: Priority;
alternates?: Alternate[];
};
const langRegex = /\/?\[(\[lang(=[a-z]+)?\]|lang(=[a-z]+)?)\]/;
const langRegexNoPath = /\[(\[lang(=[a-z]+)?\]|lang(=[a-z]+)?)\]/;
/**
* Generates an HTTP response containing an XML sitemap.
*
* @public
* @remarks Default headers set 1h CDN cache & no browser cache.
*
* @param config - Config object.
* @param config.origin - Required. Origin URL. E.g. `https://example.com`. No trailing slash
* @param config.excludeRoutePatterns - Optional. Array of regex patterns for routes to exclude.
* @param config.paramValues - Optional. Object of parameter values. See format in example below.
* @param config.additionalPaths - Optional. Array of paths to include manually. E.g. `/foo.pdf` in your `static` directory.
* @param config.headers - Optional. Custom headers. Case insensitive.
* @param config.defaultChangefreq - Optional. Default `changefreq` value to use for all paths. Omit this property to not use a default value.
* @param config.defaultPriority - Optional. Default `priority` value to use for all paths. Omit this property to not use a default value.
* @param config.processPaths - Optional. Callback function to arbitrarily process path objects.
* @param config.sort - Optional. Default is `false` and groups paths as static paths (sorted), dynamic paths (unsorted), and then additional paths (unsorted). `alpha` sorts all paths alphabetically.
* @param config.maxPerPage - Optional. Default is `50_000`, as specified in https://www.sitemaps.org/protocol.html If you have more than this, a sitemap index will be created automatically.
* @param config.page - Optional, but when using a route like `sitemap[[page]].xml to support automatic sitemap indexes. The `page` URL param.
* @returns An HTTP response containing the generated XML sitemap.
*
* @example
*
* ```js
* return await sitemap.response({
* origin: 'https://example.com',
* excludeRoutePatterns: [
* '^/dashboard.*',
* `.*\\[page=integer\\].*`
* ],
* paramValues: {
* '/blog/[slug]': ['hello-world', 'another-post']
* '/campsites/[country]/[state]': [
* ['usa', 'new-york'],
* ['usa', 'california'],
* ['canada', 'toronto']
* ],
* '/athlete-rankings/[country]/[state]': [
* {
* values: ['usa', 'new-york'],
* lastmod: '2025-01-01',
* changefreq: 'daily',
* priority: 0.5,
* },
* {
* values: ['usa', 'california'],
* lastmod: '2025-01-01',
* changefreq: 'daily',
* priority: 0.5,
* },
* ],
* },
* additionalPaths: ['/foo.pdf'],
* headers: {
* 'Custom-Header': 'blazing-fast'
* },
* changefreq: 'daily',
* priority: 0.7,
* sort: 'alpha'
* });
* ```
*/
export async function response({
additionalPaths = [],
defaultChangefreq,
defaultPriority,
excludeRoutePatterns,
headers = {},
lang,
maxPerPage = 50_000,
origin,
page,
paramValues,
processPaths,
sort = false,
}: SitemapConfig): Promise<Response> {
// Cause a 500 error for visibility
if (!origin) {
throw new Error('Sitemap: `origin` property is required in sitemap config.');
}
let paths = [
...generatePaths({
defaultChangefreq,
defaultPriority,
excludeRoutePatterns,
lang,
paramValues,
}),
...generateAdditionalPaths({
additionalPaths,
defaultChangefreq,
defaultPriority,
}),
];
if (processPaths) {
paths = processPaths(paths);
}
paths = deduplicatePaths(paths);
if (sort === 'alpha') {
paths.sort((a, b) => a.path.localeCompare(b.path));
}
const totalPages = Math.ceil(paths.length / maxPerPage);
let body: string;
if (!page) {
// User is visiting `/sitemap.xml` or `/sitemap[[page]].xml` without page.
if (paths.length <= maxPerPage) {
body = generateBody(origin, paths);
} else {
body = generateSitemapIndex(origin, totalPages);
}
} else {
// User is visiting a sitemap index's subpage–e.g. `sitemap[[page]].xml`.
// Ensure `page` param is numeric. We do it this way to avoid needing to
// instruct devs to create a route matcher, to ease set up for best DX.
if (!/^[1-9]\d*$/.test(page)) {
return new Response('Invalid page param', { status: 400 });
}
const pageInt = Number(page);
if (pageInt > totalPages) {
return new Response('Page does not exist', { status: 404 });
}
const pathsOnThisPage = paths.slice((pageInt - 1) * maxPerPage, pageInt * maxPerPage);
body = generateBody(origin, pathsOnThisPage);
}
// Merge keys case-insensitive; custom headers take precedence over defaults.
const newHeaders = {
'cache-control': 'max-age=0, s-maxage=3600', // 1h CDN cache
'content-type': 'application/xml',
...Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])
),
};
return new Response(body, { headers: newHeaders });
}
/**
* Generates an XML response body based on the provided paths, using sitemap
* structure from https://kit.svelte.dev/docs/seo#manual-setup-sitemaps.
*
* @private
* @remarks
* - Based on https://kit.svelte.dev/docs/seo#manual-setup-sitemaps
* - Google ignores changefreq and priority, but we support these optionally.
* - TODO We could consider adding `<lastmod>` with an ISO 8601 datetime, but
* not worrying about this for now.
* https://developers.google.com/search/blog/2014/10/best-practices-for-xml-sitemaps-rssatom
*
* @param origin - The origin URL. E.g. `https://example.com`. No trailing slash
* because "/" is the index page.
* @param pathObjs - Array of path objects to include in the sitemap. Each path within it should
* start with a '/'; but if not, it will be added.
* @returns The generated XML sitemap.
*/
export function generateBody(origin: string, pathObjs: PathObj[]): string {
const urlElements = pathObjs
.map((pathObj) => {
const { alternates, changefreq, lastmod, path, priority } = pathObj;
let url = '\n <url>\n';
url += ` <loc>${origin}${path}</loc>\n`;
url += lastmod ? ` <lastmod>${lastmod}</lastmod>\n` : '';
url += changefreq ? ` <changefreq>${changefreq}</changefreq>\n` : '';
url += priority ? ` <priority>${priority}</priority>\n` : '';
if (alternates) {
url += alternates
.map(
({ lang, path }) =>
` <xhtml:link rel="alternate" hreflang="${lang}" href="${origin}${path}" />\n`
)
.join('');
}
url += ' </url>';
return url;
})
.join('');
return `<?xml version="1.0" encoding="UTF-8" ?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
>${urlElements}
</urlset>`;
}
/**
* Generates a sitemap index XML string.
*
* @private
* @param origin - The origin URL. E.g. `https://example.com`. No trailing slash.
* @param pages - The number of sitemap pages to include in the index.
* @returns The generated XML sitemap index.
*/
export function generateSitemapIndex(origin: string, pages: number): string {
let str = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;
for (let i = 1; i <= pages; i++) {
str += `
<sitemap>
<loc>${origin}/sitemap${i}.xml</loc>
</sitemap>`;
}
str += `
</sitemapindex>`;
return str;
}
/**
* Generates an array of paths, based on `src/routes`, to be included in a
* sitemap.
*
* @public
*
* @param excludeRoutePatterns - Optional. An array of patterns for routes to be excluded.
* @param paramValues - Optional. An object mapping each parameterized route to
* an array of param values for that route.
* @param lang - Optional. The language configuration.
* @returns An array of strings, each representing a path for the sitemap.
*/
export function generatePaths({
defaultChangefreq,
defaultPriority,
excludeRoutePatterns = [],
lang,
paramValues = {},
}: {
excludeRoutePatterns?: string[];
paramValues?: ParamValues;
lang?: LangConfig;
defaultChangefreq: SitemapConfig['defaultChangefreq'];
defaultPriority: SitemapConfig['defaultPriority'];
}): PathObj[] {
// Match +page.svelte, [email protected], [email protected], +page@[id].svelte, and +page@(id).svelte
// - See: https://kit.svelte.dev/docs/advanced-routing#advanced-layouts-breaking-out-of-layouts
// - The `.md` and `.svx` extensions are to support MDSveX, which is a common
// markdown preprocessor for SvelteKit.
const svelteRoutes = Object.keys(import.meta.glob('/src/routes/**/+page*.svelte'));
const mdRoutes = Object.keys(import.meta.glob('/src/routes/**/+page*.md'));
const svxRoutes = Object.keys(import.meta.glob('/src/routes/**/+page*.svx'));
const allRoutes = [...svelteRoutes, ...mdRoutes, ...svxRoutes];
// Validation: if dev has one or more routes that contain a lang parameter,
// optional or required, require that they have defined the `lang.default` and
// `lang.alternates` in their config or throw an error to cause a 500 error
// for visibility.
let routesContainLangParam = false;
for (const route of allRoutes) {
if (route.match(langRegex)?.length) {
routesContainLangParam = true;
break;
}
}
if (routesContainLangParam && (!lang?.default || !lang?.alternates.length)) {
throw Error(
'Must specify `lang` property within the sitemap config because one or more routes contain [[lang]].'
);
}
// Notice this means devs MUST include `[[lang]]/` within any route strings
// used within `excludeRoutePatterns` if that's part of their route.
const filteredRoutes = filterRoutes(allRoutes, excludeRoutePatterns);
const processedRoutes = processRoutesForOptionalParams(filteredRoutes);
const { pathsWithLang, pathsWithoutLang } = generatePathsWithParamValues(
processedRoutes,
paramValues,
defaultChangefreq,
defaultPriority
);
const pathsWithLangAlternates = processPathsWithLang(pathsWithLang, lang);
return [...pathsWithoutLang, ...pathsWithLangAlternates];
}
/**
* Filters and normalizes an array of route paths.
*
* @public
*
* @param routes - An array of route strings from Vite's `import.meta.blog`.
* E.g. ['src/routes/blog/[slug]/+page.svelte', ...]
* @param excludeRoutePatterns - An array of regular expression patterns to match
* routes to exclude.
* @returns A sorted array of cleaned-up route strings.
* E.g. ['/blog/[slug]', ...]
*
* @remarks
* - Removes trailing slashes from routes, except for the homepage route. If
* SvelteKit specified this option in a config, rather than layouts, we could
* read the user's preference, but it doesn't, we use SvelteKit's default no
* trailing slash https://kit.svelte.dev/docs/page-options#trailingslash
*/
export function filterRoutes(routes: string[], excludeRoutePatterns: string[]): string[] {
return (
routes
// Remove `/src/routes` prefix, `+page.svelte suffix` or any variation
// like `[email protected]`, and trailing slash except on homepage. Trailing
// slash must be removed before excludeRoutePatterns so `$` termination of a
// regex pattern will work as expected.
.map((x) => {
// Don't trim initial '/' yet, b/c a developer's excludeRoutePatterns may start with it.
x = x.substring(11);
x = x.replace(/\/\+page.*\.(svelte|md|svx)$/, '');
return !x ? '/' : x;
})
// Remove any routes that match an exclude pattern
.filter((x) => !excludeRoutePatterns.some((pattern) => new RegExp(pattern).test(x)))
// Remove initial `/` now and any `/(groups)`, because decorative only.
// Must follow excludeRoutePatterns. Ensure index page is '/' in case it was
// part of a group. The pattern to match the group is from
// https://github.com/sveltejs/kit/blob/99cddbfdb2332111d348043476462f5356a23660/packages/kit/src/utils/routing.js#L119
.map((x) => {
x = x.replaceAll(/\/\([^)]+\)/g, '');
return !x ? '/' : x;
})
.sort()
);
}
/**
* Builds parameterized paths using paramValues provided (e.g.
* `/blog/hello-world`) and then removes the respective tokenized route (e.g.
* `/blog/[slug]`) from the routes array.
*
* @public
*
* @param routes - An array of route strings, including parameterized routes
* E.g. ['/', '/about', '/blog/[slug]', /blog/tags/[tag]']
* @param paramValues - An object mapping parameterized routes to a 1D or 2D
* array of their parameter's values. E.g.
* {
* '/blog/[slug]': ['hello-world', 'another-post']
* '/campsites/[country]/[state]': [
* ['usa','miami'],
* ['usa','new-york'],
* ['canada','toronto']
* ],
* '/athlete-rankings/[country]/[state]':[
* {
* params: ['usa', 'new-york'],
* lastmod: '2024-01-01',
* changefreq: 'daily',
* priority: 0.5,
* },
* {
* params: ['usa', 'california'],
* lastmod: '2024-01-01',
* changefreq: 'daily',
* priority: 0.5,
* },
* ]
* }
*
*
* @returns A tuple where the first element is an array of routes and the second
* element is an array of generated parameterized paths.
*
* @throws Will throw an error if a `paramValues` key doesn't correspond to an
* existing route, for visibility to the developer.
* @throws Will throw an error if a parameterized route does not have data
* within paramValues, for visibility to the developer.
*/
export function generatePathsWithParamValues(
routes: string[],
paramValues: ParamValues,
defaultChangefreq: SitemapConfig['defaultChangefreq'],
defaultPriority: SitemapConfig['defaultPriority']
): { pathsWithLang: PathObj[]; pathsWithoutLang: PathObj[] } {
// Throw if paramValues contains keys that don't exist within src/routes/.
for (const paramValueKey in paramValues) {
if (!routes.includes(paramValueKey)) {
throw new Error(
`Sitemap: paramValues were provided for a route that does not exist within src/routes/: '${paramValueKey}'. Remove this property from your paramValues.`
);
}
}
// `changefreq`, `lastmod`, & `priority` are intentionally left with undefined values (for
// consistency of property name within the `processPaths() callback, if used) when the dev does
// not specify them either in pathObj or as defaults in the sitemap config.
const defaults = {
changefreq: defaultChangefreq,
lastmod: undefined,
priority: defaultPriority,
};
let pathsWithLang: PathObj[] = [];
let pathsWithoutLang: PathObj[] = [];
for (const paramValuesKey in paramValues) {
const hasLang = langRegex.exec(paramValuesKey);
const routeSansLang = paramValuesKey.replace(langRegex, '');
const paramValue = paramValues[paramValuesKey];
let pathObjs: PathObj[] = [];
// Handle when paramValue contains ParamValueObj[]
if (typeof paramValue[0] === 'object' && !Array.isArray(paramValue[0])) {
const objArray = paramValue as ParamValue[];
pathObjs.push(
...objArray.map((item) => {
let i = 0;
return {
changefreq: item.changefreq ?? defaults.changefreq,
lastmod: item.lastmod,
path: routeSansLang.replace(/(\[\[.+?\]\]|\[.+?\])/g, () => item.values[i++] || ''),
priority: item.priority ?? defaults.priority,
};
})
);
} else if (Array.isArray(paramValue[0])) {
// Handle when paramValue contains a 2D array of strings (e.g. [['usa', 'new-york'], ['usa',
// 'california']])
// - `replace()` replaces every [[foo]] or [foo] with a value from the array.
const array2D = paramValue as string[][];
pathObjs = array2D.map((data) => {
let i = 0;
return {
...defaults,
path: routeSansLang.replace(/(\[\[.+?\]\]|\[.+?\])/g, () => data[i++] || ''),
};
});
} else {
// Handle 1D array of strings (e.g. ['hello-world', 'another-post', 'foo-post']) to generate
// paths using these param values.
const array1D = paramValue as string[];
pathObjs = array1D.map((paramValue) => ({
...defaults,
path: routeSansLang.replace(/\[.*\]/, paramValue),
}));
}
// Process path objects to add lang onto each path, when applicable.
if (hasLang) {
const lang = hasLang?.[0];
pathsWithLang.push(
...pathObjs.map((pathObj) => ({
...pathObj,
path: pathObj.path.slice(0, hasLang?.index) + lang + pathObj.path.slice(hasLang?.index),
}))
);
} else {
pathsWithoutLang.push(...pathObjs);
}
// Remove this from routes
routes.splice(routes.indexOf(paramValuesKey), 1);
}
// Handle "static" routes (i.e. /foo, /[[lang]]/bar, etc). These will not have any parameters
// other than exactly `[[lang]]`.
const staticWithLang: PathObj[] = [];
const staticWithoutLang: PathObj[] = [];
for (const route of routes) {
const hasLang = route.match(langRegex);
if (hasLang) {
staticWithLang.push({ ...defaults, path: route });
} else {
staticWithoutLang.push({ ...defaults, path: route });
}
}
// This just keeps static paths first, which I prefer.
pathsWithLang = [...staticWithLang, ...pathsWithLang];
pathsWithoutLang = [...staticWithoutLang, ...pathsWithoutLang];
// Check for missing paramValues.
// Throw error if app contains any parameterized routes NOT handled in the
// sitemap, to alert the developer. Prevents accidental omission of any paths.
for (const route of routes) {
// Check whether any instance of [foo] or [[foo]] exists
const regex = /.*(\[\[.+\]\]|\[.+\]).*/;
const routeSansLang = route.replace(langRegex, '') || '/';
if (regex.test(routeSansLang)) {
throw new Error(
`Sitemap: paramValues not provided for: '${route}'\nUpdate your sitemap's excludedRoutePatterns to exclude this route OR add data for this route's param(s) to the paramValues object of your sitemap config.`
);
}
}
return { pathsWithLang, pathsWithoutLang };
}
/**
* Given an array of all routes, return a new array of routes that includes all versions of each
* route that contains one or more optional params _other than_ `[[lang]]`.
*
* @private
*/
export function processRoutesForOptionalParams(routes: string[]): string[] {
const processedRoutes = routes.flatMap((route) => {
const routeWithoutLangIfAny = route.replace(langRegex, '');
return /\[\[.*\]\]/.test(routeWithoutLangIfAny) ? processOptionalParams(route) : route;
});
// Ensure no duplicates exist after processing
return Array.from(new Set(processedRoutes));
}
/**
* Processes a route containing >=1 optional parameters (i.e. those with double square brackets) to
* generate all possible versions of this route that SvelteKit considers valid.
*
* @private
* @param route - Route to process. E.g. `/foo/[[paramA]]`
* @returns An array of routes. E.g. [`/foo`, `/foo/[[paramA]]`]
*/
export function processOptionalParams(originalRoute: string): string[] {
// Remove lang to simplify
const hasLang = langRegex.exec(originalRoute);
const route = hasLang ? originalRoute.replace(langRegex, '') : originalRoute;
let results: string[] = [];
// Get path up to _before_ the first optional param; use `i-1` to exclude
// trailing slash after this. This is our first result.
results.push(route.slice(0, route.indexOf('[[') - 1));
// Extract the portion of the route starting at the first optional parameter
const remaining = route.slice(route.indexOf('[['));
// Split, then filter to remove empty items.
const segments = remaining.split('/').filter(Boolean);
let j = 1;
for (const segment of segments) {
// Start a new potential result
if (!results[j]) results[j] = results[j - 1];
results[j] = `${results[j]}/${segment}`;
if (segment.startsWith('[[')) {
j++;
}
}
// Re-add lang to all results.
if (hasLang) {
const lang = hasLang?.[0];
results = results.map(
(result) => `${result.slice(0, hasLang?.index)}${lang}${result.slice(hasLang?.index)}`
);
}
// When the first path segment is an optional parameter (except for [[lang]]), the first result
// will be an empty string. We set this to '/' b/c the root path is one of the valid paths
// combinations in such a scenario.
if (!results[0].length) results[0] = '/';
return results;
}
/**
* Processes path objects that contain `[[lang]]` or `[lang]` to 1.) generate one PathObj for each
* language in the lang config, and 2.) to add an `alternates` property to each such PathObj.
*
* @private
*/
export function processPathsWithLang(pathObjs: PathObj[], langConfig: LangConfig): PathObj[] {
if (!pathObjs.length) return [];
const processedPathObjs = [];
for (const pathObj of pathObjs) {
const path = pathObj.path;
// The Sitemap standard specifies for hreflang elements to include 1.) the
// current path itself, and 2.) all of its alternates. So all versions of
// this path will be given the same "variations" array that will be used to
// build hreflang items for the path.
// https://developers.google.com/search/blog/2012/05/multilingual-and-multinational-site
// - If the lang param is required (i.e. `[lang]`), all variations of this
// path must include the lang param within the path.
// - If the lang param is optional (i.e. `[[lang]]`), the default lang will
// not contain the language in the path but all other variations will.
const hasLangRequired = /\/?\[lang(=[a-z]+)?\](?!\])/.exec(path);
const _path = hasLangRequired
? path.replace(langRegex, `/${langConfig.default}`)
: path.replace(langRegex, '') || '/';
// Add the default path (e.g. '/about', or `/es/about` when lang is required).
const variations = [
{
lang: langConfig.default,
path: _path,
},
];
// Add alternate paths (e.g. '/de/about', etc.)
for (const lang of langConfig.alternates) {
variations.push({
lang,
path: path.replace(langRegexNoPath, lang),
});
}
// Generate a PathObj for each variation.
const pathObjs = [];
for (const x of variations) {
pathObjs.push({
...pathObj, // keep original pathObj properties
alternates: variations,
path: x.path,
});
}
processedPathObjs.push(...pathObjs);
}
return processedPathObjs;
}
/**
* Removes duplicate paths from an array of PathObj, keeping the last occurrence of any duplicates.
*
* - Duplicate pathObjs could occur due to a developer using additionalPaths or processPaths() and
* not properly excluding a pre-existing path.
*
* @private
*/
export function deduplicatePaths(pathObjs: PathObj[]): PathObj[] {
const uniquePaths = new Map<string, PathObj>();
for (const pathObj of pathObjs) {
uniquePaths.set(pathObj.path, pathObj);
}
return Array.from(uniquePaths.values());
}
/**
* Converts the user-provided `additionalPaths` into `PathObj[]` type, ensuring each path starts
* with a forward slash and each PathObj contains default changefreq and priority.
*
* - `additionalPaths` are never translated based on the lang config because they could be something
* like a PDF within the user's static dir.
*
* @private
*/
export function generateAdditionalPaths({
additionalPaths,
defaultChangefreq,
defaultPriority,
}: {
additionalPaths: string[];
defaultChangefreq: SitemapConfig['defaultChangefreq'];
defaultPriority: SitemapConfig['defaultPriority'];
}): PathObj[] {
const defaults = {
changefreq: defaultChangefreq,
lastmod: undefined,
priority: defaultPriority,
};
return additionalPaths.map((path) => ({
...defaults,
path: path.startsWith('/') ? path : `/${path}`,
}));
}