diff --git a/__tests__/sitemaps.ts b/__tests__/sitemaps.ts
index c1a930f374..49e061e13d 100644
--- a/__tests__/sitemaps.ts
+++ b/__tests__/sitemaps.ts
@@ -1133,7 +1133,7 @@ describe('GET /sitemaps/archive-index.xml', () => {
rankingType: ArchiveRankingType.Best,
};
- it('should return index pages for tags and sources with archives', async () => {
+ it('should return index pages for global, tag, and source archives', async () => {
const createdAt = new Date('2025-03-01T10:00:00.000Z');
await con.getRepository(Archive).save([
@@ -1190,11 +1190,14 @@ describe('GET /sitemaps/archive-index.xml', () => {
expect(res.text).toContain(
'http://localhost:5002/tags/rust/best-of',
);
- // Global archives should not appear
- expect(res.text).not.toContain('/best-of\n');
+ expect(res.text).toContain(
+ 'http://localhost:5002/posts/best-of',
+ );
// Only one entry for rust (two archives but one index)
const rustMatches = res.text.match(/\/tags\/rust\/best-of<\/loc>/g);
expect(rustMatches).toHaveLength(1);
+ const globalMatches = res.text.match(/\/posts\/best-of<\/loc>/g);
+ expect(globalMatches).toHaveLength(1);
});
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', () => {
);
});
+ it('should return global monthly archive pages', async () => {
+ const createdAt = new Date('2025-04-01T10:00:00.000Z');
+
+ await con.getRepository(Archive).save([
+ {
+ ...archiveBase,
+ scopeType: ArchiveScopeType.Global,
+ scopeId: null,
+ periodType: ArchivePeriodType.Month,
+ periodStart: new Date('2025-03-01T00:00:00.000Z'),
+ createdAt,
+ },
+ ]);
+
+ const res = await request(app.server)
+ .get('/sitemaps/archive-pages-global-month-0.xml')
+ .expect(200);
+
+ expect(res.text).toContain(
+ 'http://localhost:5002/posts/best-of/2025/03',
+ );
+ });
+
+ it('should return global yearly archive pages', async () => {
+ const createdAt = new Date('2025-04-01T10:00:00.000Z');
+
+ await con.getRepository(Archive).save([
+ {
+ ...archiveBase,
+ scopeType: ArchiveScopeType.Global,
+ scopeId: null,
+ periodType: ArchivePeriodType.Year,
+ periodStart: new Date('2024-01-01T00:00:00.000Z'),
+ createdAt,
+ },
+ ]);
+
+ const res = await request(app.server)
+ .get('/sitemaps/archive-pages-global-year-0.xml')
+ .expect(200);
+
+ expect(res.text).toContain(
+ 'http://localhost:5002/posts/best-of/2024',
+ );
+ });
+
it('should return 404 for invalid scopeType', async () => {
await request(app.server)
.get('/sitemaps/archive-pages-invalid-month-0.xml')
@@ -1416,6 +1465,14 @@ describe('GET /sitemaps/index.xml (archive entries)', () => {
it('should include archive-index and paginated archive-pages sitemaps', async () => {
await con.getRepository(Archive).save([
+ {
+ ...archiveBase,
+ scopeType: ArchiveScopeType.Global,
+ scopeId: null,
+ periodType: ArchivePeriodType.Month,
+ periodStart: new Date('2025-02-01T00:00:00.000Z'),
+ createdAt: new Date(),
+ },
{
...archiveBase,
scopeType: ArchiveScopeType.Tag,
@@ -1441,6 +1498,9 @@ describe('GET /sitemaps/index.xml (archive entries)', () => {
expect(res.text).toContain(
'http://localhost:5002/api/sitemaps/archive-index.xml',
);
+ expect(res.text).toContain(
+ 'http://localhost:5002/api/sitemaps/archive-pages-global-month-0.xml',
+ );
expect(res.text).toContain(
'http://localhost:5002/api/sitemaps/archive-pages-tag-month-0.xml',
);
diff --git a/src/routes/sitemaps.ts b/src/routes/sitemaps.ts
index ebe3b431e9..ce2d00096a 100644
--- a/src/routes/sitemaps.ts
+++ b/src/routes/sitemaps.ts
@@ -418,17 +418,32 @@ const zeroPadMonth = (month: number): string =>
const getArchiveBestOfUrl = (
prefix: string,
scopeType: ArchiveScopeType,
- scopeId: string,
+ scopeId: string | null,
): string => {
- const segment = scopeType === ArchiveScopeType.Tag ? 'tags' : 'sources';
+ switch (scopeType) {
+ case ArchiveScopeType.Global:
+ return `${prefix}/posts/best-of`;
+ case ArchiveScopeType.Tag:
+ if (!scopeId) {
+ throw new Error('Archive tag sitemap URL requires a scopeId');
+ }
+
+ return `${prefix}/tags/${encodeURIComponent(scopeId)}/best-of`;
+ case ArchiveScopeType.Source:
+ if (!scopeId) {
+ throw new Error('Archive source sitemap URL requires a scopeId');
+ }
+
+ return `${prefix}/sources/${encodeURIComponent(scopeId)}/best-of`;
+ }
- return `${prefix}/${segment}/${encodeURIComponent(scopeId)}/best-of`;
+ throw new Error(`Unsupported archive scope type: ${scopeType}`);
};
const getArchivePageUrl = (
prefix: string,
scopeType: ArchiveScopeType,
- scopeId: string,
+ scopeId: string | null,
periodType: ArchivePeriodType,
periodStart: Date,
): string => {
@@ -462,7 +477,11 @@ const buildArchiveIndexSitemapQuery = (
`a."scopeType" = '${ArchiveScopeType.Source}' AND s.id = a."scopeId"`,
)
.where('a."scopeType" IN (:...scopeTypes)', {
- scopeTypes: [ArchiveScopeType.Tag, ArchiveScopeType.Source],
+ scopeTypes: [
+ ArchiveScopeType.Global,
+ ArchiveScopeType.Tag,
+ ArchiveScopeType.Source,
+ ],
})
.andWhere(
`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 = (
.limit(DEFAULT_SITEMAP_LIMIT);
const VALID_ARCHIVE_SCOPE_TYPES = new Set([
+ ArchiveScopeType.Global,
ArchiveScopeType.Tag,
ArchiveScopeType.Source,
]);
@@ -497,7 +517,11 @@ const buildArchivePagesPaginatedQuery = (
.createQueryBuilder()
.select('a."scopeType"', 'scopeType')
.addSelect(
- scopeType === ArchiveScopeType.Source ? 's.handle' : 'a."scopeId"',
+ scopeType === ArchiveScopeType.Global
+ ? 'NULL'
+ : scopeType === ArchiveScopeType.Source
+ ? 's.handle'
+ : 'a."scopeId"',
'scopeId',
)
.addSelect('a."periodType"', 'periodType')
@@ -507,15 +531,18 @@ const buildArchivePagesPaginatedQuery = (
.where('a."scopeType" = :scopeType', { scopeType })
.andWhere('a."periodType" = :periodType', { periodType });
- if (scopeType === ArchiveScopeType.Source) {
- qb.innerJoin(
- Source,
- 's',
- `s.id = a."scopeId" AND s.type != '${SourceType.Squad}'`,
- );
- qb.orderBy('s.handle', 'ASC');
- } else {
- qb.orderBy('a."scopeId"', 'ASC');
+ switch (scopeType) {
+ case ArchiveScopeType.Source:
+ qb.innerJoin(
+ Source,
+ 's',
+ `s.id = a."scopeId" AND s.type != '${SourceType.Squad}'`,
+ );
+ qb.orderBy('s.handle', 'ASC');
+ break;
+ case ArchiveScopeType.Tag:
+ qb.orderBy('a."scopeId"', 'ASC');
+ break;
}
qb.addOrderBy('a."periodStart"', 'ASC')
@@ -538,7 +565,11 @@ const getArchivePagesCount = async (
.addSelect('COUNT(*)', 'count')
.from(Archive, 'a')
.where('a."scopeType" IN (:...scopeTypes)', {
- scopeTypes: [ArchiveScopeType.Tag, ArchiveScopeType.Source],
+ scopeTypes: [
+ ArchiveScopeType.Global,
+ ArchiveScopeType.Tag,
+ ArchiveScopeType.Source,
+ ],
})
.groupBy('a."scopeType"')
.addGroupBy('a."periodType"')