Skip to content

Commit 36c59c5

Browse files
authored
feat: add sitemap coverage for global best-of pages (#3789)
1 parent 989fb37 commit 36c59c5

2 files changed

Lines changed: 110 additions & 19 deletions

File tree

__tests__/sitemaps.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,7 @@ describe('GET /sitemaps/archive-index.xml', () => {
11331133
rankingType: ArchiveRankingType.Best,
11341134
};
11351135

1136-
it('should return index pages for tags and sources with archives', async () => {
1136+
it('should return index pages for global, tag, and source archives', async () => {
11371137
const createdAt = new Date('2025-03-01T10:00:00.000Z');
11381138

11391139
await con.getRepository(Archive).save([
@@ -1190,11 +1190,14 @@ describe('GET /sitemaps/archive-index.xml', () => {
11901190
expect(res.text).toContain(
11911191
'<loc>http://localhost:5002/tags/rust/best-of</loc>',
11921192
);
1193-
// Global archives should not appear
1194-
expect(res.text).not.toContain('/best-of</loc>\n');
1193+
expect(res.text).toContain(
1194+
'<loc>http://localhost:5002/posts/best-of</loc>',
1195+
);
11951196
// Only one entry for rust (two archives but one index)
11961197
const rustMatches = res.text.match(/\/tags\/rust\/best-of<\/loc>/g);
11971198
expect(rustMatches).toHaveLength(1);
1199+
const globalMatches = res.text.match(/\/posts\/best-of<\/loc>/g);
1200+
expect(globalMatches).toHaveLength(1);
11981201
});
11991202

12001203
it('should exclude source archives when the source has been deleted', async () => {
@@ -1334,6 +1337,52 @@ describe('GET /sitemaps/archive-pages-:scopeType-:periodType-:page.xml', () => {
13341337
);
13351338
});
13361339

1340+
it('should return global monthly archive pages', async () => {
1341+
const createdAt = new Date('2025-04-01T10:00:00.000Z');
1342+
1343+
await con.getRepository(Archive).save([
1344+
{
1345+
...archiveBase,
1346+
scopeType: ArchiveScopeType.Global,
1347+
scopeId: null,
1348+
periodType: ArchivePeriodType.Month,
1349+
periodStart: new Date('2025-03-01T00:00:00.000Z'),
1350+
createdAt,
1351+
},
1352+
]);
1353+
1354+
const res = await request(app.server)
1355+
.get('/sitemaps/archive-pages-global-month-0.xml')
1356+
.expect(200);
1357+
1358+
expect(res.text).toContain(
1359+
'<loc>http://localhost:5002/posts/best-of/2025/03</loc>',
1360+
);
1361+
});
1362+
1363+
it('should return global yearly archive pages', async () => {
1364+
const createdAt = new Date('2025-04-01T10:00:00.000Z');
1365+
1366+
await con.getRepository(Archive).save([
1367+
{
1368+
...archiveBase,
1369+
scopeType: ArchiveScopeType.Global,
1370+
scopeId: null,
1371+
periodType: ArchivePeriodType.Year,
1372+
periodStart: new Date('2024-01-01T00:00:00.000Z'),
1373+
createdAt,
1374+
},
1375+
]);
1376+
1377+
const res = await request(app.server)
1378+
.get('/sitemaps/archive-pages-global-year-0.xml')
1379+
.expect(200);
1380+
1381+
expect(res.text).toContain(
1382+
'<loc>http://localhost:5002/posts/best-of/2024</loc>',
1383+
);
1384+
});
1385+
13371386
it('should return 404 for invalid scopeType', async () => {
13381387
await request(app.server)
13391388
.get('/sitemaps/archive-pages-invalid-month-0.xml')
@@ -1416,6 +1465,14 @@ describe('GET /sitemaps/index.xml (archive entries)', () => {
14161465

14171466
it('should include archive-index and paginated archive-pages sitemaps', async () => {
14181467
await con.getRepository(Archive).save([
1468+
{
1469+
...archiveBase,
1470+
scopeType: ArchiveScopeType.Global,
1471+
scopeId: null,
1472+
periodType: ArchivePeriodType.Month,
1473+
periodStart: new Date('2025-02-01T00:00:00.000Z'),
1474+
createdAt: new Date(),
1475+
},
14191476
{
14201477
...archiveBase,
14211478
scopeType: ArchiveScopeType.Tag,
@@ -1441,6 +1498,9 @@ describe('GET /sitemaps/index.xml (archive entries)', () => {
14411498
expect(res.text).toContain(
14421499
'<loc>http://localhost:5002/api/sitemaps/archive-index.xml</loc>',
14431500
);
1501+
expect(res.text).toContain(
1502+
'<loc>http://localhost:5002/api/sitemaps/archive-pages-global-month-0.xml</loc>',
1503+
);
14441504
expect(res.text).toContain(
14451505
'<loc>http://localhost:5002/api/sitemaps/archive-pages-tag-month-0.xml</loc>',
14461506
);

src/routes/sitemaps.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -418,17 +418,32 @@ const zeroPadMonth = (month: number): string =>
418418
const getArchiveBestOfUrl = (
419419
prefix: string,
420420
scopeType: ArchiveScopeType,
421-
scopeId: string,
421+
scopeId: string | null,
422422
): string => {
423-
const segment = scopeType === ArchiveScopeType.Tag ? 'tags' : 'sources';
423+
switch (scopeType) {
424+
case ArchiveScopeType.Global:
425+
return `${prefix}/posts/best-of`;
426+
case ArchiveScopeType.Tag:
427+
if (!scopeId) {
428+
throw new Error('Archive tag sitemap URL requires a scopeId');
429+
}
430+
431+
return `${prefix}/tags/${encodeURIComponent(scopeId)}/best-of`;
432+
case ArchiveScopeType.Source:
433+
if (!scopeId) {
434+
throw new Error('Archive source sitemap URL requires a scopeId');
435+
}
436+
437+
return `${prefix}/sources/${encodeURIComponent(scopeId)}/best-of`;
438+
}
424439

425-
return `${prefix}/${segment}/${encodeURIComponent(scopeId)}/best-of`;
440+
throw new Error(`Unsupported archive scope type: ${scopeType}`);
426441
};
427442

428443
const getArchivePageUrl = (
429444
prefix: string,
430445
scopeType: ArchiveScopeType,
431-
scopeId: string,
446+
scopeId: string | null,
432447
periodType: ArchivePeriodType,
433448
periodStart: Date,
434449
): string => {
@@ -462,7 +477,11 @@ const buildArchiveIndexSitemapQuery = (
462477
`a."scopeType" = '${ArchiveScopeType.Source}' AND s.id = a."scopeId"`,
463478
)
464479
.where('a."scopeType" IN (:...scopeTypes)', {
465-
scopeTypes: [ArchiveScopeType.Tag, ArchiveScopeType.Source],
480+
scopeTypes: [
481+
ArchiveScopeType.Global,
482+
ArchiveScopeType.Tag,
483+
ArchiveScopeType.Source,
484+
],
466485
})
467486
.andWhere(
468487
`CASE WHEN a."scopeType" = '${ArchiveScopeType.Source}' THEN s.handle IS NOT NULL AND s.type != '${SourceType.Squad}' ELSE TRUE END`,
@@ -479,6 +498,7 @@ const buildArchiveIndexSitemapQuery = (
479498
.limit(DEFAULT_SITEMAP_LIMIT);
480499

481500
const VALID_ARCHIVE_SCOPE_TYPES = new Set<string>([
501+
ArchiveScopeType.Global,
482502
ArchiveScopeType.Tag,
483503
ArchiveScopeType.Source,
484504
]);
@@ -497,7 +517,11 @@ const buildArchivePagesPaginatedQuery = (
497517
.createQueryBuilder()
498518
.select('a."scopeType"', 'scopeType')
499519
.addSelect(
500-
scopeType === ArchiveScopeType.Source ? 's.handle' : 'a."scopeId"',
520+
scopeType === ArchiveScopeType.Global
521+
? 'NULL'
522+
: scopeType === ArchiveScopeType.Source
523+
? 's.handle'
524+
: 'a."scopeId"',
501525
'scopeId',
502526
)
503527
.addSelect('a."periodType"', 'periodType')
@@ -507,15 +531,18 @@ const buildArchivePagesPaginatedQuery = (
507531
.where('a."scopeType" = :scopeType', { scopeType })
508532
.andWhere('a."periodType" = :periodType', { periodType });
509533

510-
if (scopeType === ArchiveScopeType.Source) {
511-
qb.innerJoin(
512-
Source,
513-
's',
514-
`s.id = a."scopeId" AND s.type != '${SourceType.Squad}'`,
515-
);
516-
qb.orderBy('s.handle', 'ASC');
517-
} else {
518-
qb.orderBy('a."scopeId"', 'ASC');
534+
switch (scopeType) {
535+
case ArchiveScopeType.Source:
536+
qb.innerJoin(
537+
Source,
538+
's',
539+
`s.id = a."scopeId" AND s.type != '${SourceType.Squad}'`,
540+
);
541+
qb.orderBy('s.handle', 'ASC');
542+
break;
543+
case ArchiveScopeType.Tag:
544+
qb.orderBy('a."scopeId"', 'ASC');
545+
break;
519546
}
520547

521548
qb.addOrderBy('a."periodStart"', 'ASC')
@@ -538,7 +565,11 @@ const getArchivePagesCount = async (
538565
.addSelect('COUNT(*)', 'count')
539566
.from(Archive, 'a')
540567
.where('a."scopeType" IN (:...scopeTypes)', {
541-
scopeTypes: [ArchiveScopeType.Tag, ArchiveScopeType.Source],
568+
scopeTypes: [
569+
ArchiveScopeType.Global,
570+
ArchiveScopeType.Tag,
571+
ArchiveScopeType.Source,
572+
],
542573
})
543574
.groupBy('a."scopeType"')
544575
.addGroupBy('a."periodType"')

0 commit comments

Comments
 (0)