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"')