Skip to content

Commit 7c7ac41

Browse files
authored
feat: add highlights sitemap (#3795)
1 parent dcc5608 commit 7c7ac41

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

__tests__/sitemaps.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { updateFlagsStatement } from '../src/common/utils';
3333
import { sourcesFixture } from './fixture/source';
3434
import { keywordsFixture } from './fixture/keywords';
3535
import { ONE_DAY_IN_SECONDS } from '../src/common/constants';
36+
import { ChannelHighlightDefinition } from '../src/entity/ChannelHighlightDefinition';
37+
import { PostHighlight } from '../src/entity/PostHighlight';
3638
let app: FastifyInstance;
3739
let con: DataSource;
3840
const previousSitemapLimit = process.env.SITEMAP_LIMIT;
@@ -150,6 +152,7 @@ beforeAll(async () => {
150152

151153
beforeEach(async () => {
152154
nock.cleanAll();
155+
await con.getRepository(ChannelHighlightDefinition).clear();
153156
await saveFixtures(con, SentimentGroup, sentimentGroupsFixture);
154157
await saveFixtures(con, SentimentEntity, sentimentEntitiesFixture);
155158
await saveFixtures(con, Keyword, keywordsFixture);
@@ -524,6 +527,9 @@ describe('GET /sitemaps/index.xml', () => {
524527
expect(res.text).toContain(
525528
'<loc>http://localhost:5002/api/sitemaps/collections.xml</loc>',
526529
);
530+
expect(res.text).toContain(
531+
'<loc>http://localhost:5002/api/sitemaps/highlights.xml</loc>',
532+
);
527533
expect(res.text).toContain(
528534
'<loc>http://localhost:5002/api/sitemaps/agents.xml</loc>',
529535
);
@@ -545,6 +551,86 @@ describe('GET /sitemaps/index.xml', () => {
545551
});
546552
});
547553

554+
describe('GET /sitemaps/highlights.xml', () => {
555+
it('should return the highlights sitemap with latest live highlight lastmod per channel', async () => {
556+
await con.getRepository(ChannelHighlightDefinition).save([
557+
{
558+
channel: 'career',
559+
displayName: 'Career',
560+
mode: 'shadow',
561+
order: 1,
562+
},
563+
{
564+
channel: 'backend',
565+
displayName: 'Backend',
566+
mode: 'publish',
567+
order: 2,
568+
},
569+
{
570+
channel: 'disabled',
571+
displayName: 'Disabled',
572+
mode: 'disabled',
573+
order: 0,
574+
},
575+
]);
576+
await con.getRepository(PostHighlight).save([
577+
{
578+
postId: 'p1',
579+
channel: 'career',
580+
highlightedAt: new Date('2026-04-10T10:00:00.000Z'),
581+
headline: 'Career early',
582+
},
583+
{
584+
postId: 'p4',
585+
channel: 'career',
586+
highlightedAt: new Date('2026-04-12T09:00:00.000Z'),
587+
headline: 'Career latest',
588+
},
589+
{
590+
postId: 'p1',
591+
channel: 'backend',
592+
highlightedAt: new Date('2026-04-09T08:00:00.000Z'),
593+
headline: 'Backend live',
594+
},
595+
{
596+
postId: 'p4',
597+
channel: 'backend',
598+
highlightedAt: new Date('2026-04-13T08:00:00.000Z'),
599+
headline: 'Backend retired',
600+
retiredAt: new Date('2026-04-13T08:30:00.000Z'),
601+
},
602+
]);
603+
604+
const res = await request(app.server)
605+
.get('/sitemaps/highlights.xml')
606+
.expect(200);
607+
608+
expect(res.header['content-type']).toContain('application/xml');
609+
expect(res.header['cache-control']).toBeTruthy();
610+
expect(res.text).toContain(
611+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
612+
);
613+
expect(res.text).toContain(
614+
'<loc>http://localhost:5002/highlights</loc><lastmod>2026-04-12T09:00:00.000Z</lastmod>',
615+
);
616+
expect(res.text).toContain(
617+
'<loc>http://localhost:5002/highlights/career</loc><lastmod>2026-04-12T09:00:00.000Z</lastmod>',
618+
);
619+
expect(res.text).toContain(
620+
'<loc>http://localhost:5002/highlights/backend</loc><lastmod>2026-04-09T08:00:00.000Z</lastmod>',
621+
);
622+
expect(res.text).not.toContain('/highlights/disabled');
623+
expect(res.text).not.toContain('2026-04-13T08:00:00.000Z');
624+
625+
expect(res.text.indexOf('/highlights</loc>')).toBeLessThan(
626+
res.text.indexOf('/highlights/career</loc>'),
627+
);
628+
expect(res.text.indexOf('/highlights/career</loc>')).toBeLessThan(
629+
res.text.indexOf('/highlights/backend</loc>'),
630+
);
631+
});
632+
});
633+
548634
describe('GET /sitemaps/sources.xml', () => {
549635
it('should include only qualified public machine sources', async () => {
550636
const sourceCreatedAt = new Date('2023-10-01T10:00:00.000Z');

src/routes/sitemaps.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
User,
1212
} from '../entity';
1313
import { AGENTS_DIGEST_SOURCE } from '../entity/Source';
14+
import { ChannelHighlightDefinition } from '../entity/ChannelHighlightDefinition';
15+
import { PostHighlight } from '../entity/PostHighlight';
1416
import { ArchivePeriodType, ArchiveScopeType } from '../common/archive';
1517
import { getUserProfileUrl } from '../common/users';
1618
import createOrGetConnection from '../db';
@@ -98,6 +100,29 @@ const getSourceSitemapUrl = (prefix: string, handle: string): string =>
98100
const getSquadSitemapUrl = (prefix: string, handle: string): string =>
99101
`${prefix}/squads/${encodeURIComponent(handle)}`;
100102

103+
const getHighlightsSitemapUrl = (prefix: string, channel?: string): string =>
104+
channel
105+
? `${prefix}/highlights/${encodeURIComponent(channel)}`
106+
: `${prefix}/highlights`;
107+
108+
type SitemapUrlEntry = {
109+
url: string;
110+
lastmod?: string;
111+
};
112+
113+
const getSitemapUrlSetXml = (
114+
entries: SitemapUrlEntry[],
115+
): string => `<?xml version="1.0" encoding="UTF-8"?>
116+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
117+
${entries
118+
.map(({ url, lastmod }) =>
119+
lastmod
120+
? ` <url><loc>${escapeXml(url)}</loc><lastmod>${escapeXml(lastmod)}</lastmod></url>`
121+
: ` <url><loc>${escapeXml(url)}</loc></url>`,
122+
)
123+
.join('\n')}
124+
</urlset>`;
125+
101126
const streamReplicaQuery = async <T extends ObjectLiteral>(
102127
con: DataSource,
103128
buildQuery: (source: EntityManager) => SelectQueryBuilder<T>,
@@ -299,6 +324,26 @@ const buildCollectionsSitemapQuery = (
299324
.limit(DEFAULT_SITEMAP_LIMIT),
300325
);
301326

327+
const buildHighlightsSitemapQuery = (
328+
source: DataSource | EntityManager,
329+
): SelectQueryBuilder<ChannelHighlightDefinition> =>
330+
source
331+
.createQueryBuilder()
332+
.select('chd.channel', 'channel')
333+
.addSelect('MAX(ph."highlightedAt")', 'lastmod')
334+
.from(ChannelHighlightDefinition, 'chd')
335+
.leftJoin(
336+
PostHighlight,
337+
'ph',
338+
'ph.channel = chd.channel AND ph."retiredAt" IS NULL',
339+
)
340+
.where('chd.mode != :disabledMode', { disabledMode: 'disabled' })
341+
.groupBy('chd.channel')
342+
.addGroupBy('chd."order"')
343+
.orderBy('chd."order"', 'ASC')
344+
.addOrderBy('chd.channel', 'ASC')
345+
.limit(DEFAULT_SITEMAP_LIMIT);
346+
302347
const buildTagsSitemapQuery = (
303348
source: DataSource | EntityManager,
304349
): SelectQueryBuilder<Keyword> =>
@@ -617,6 +662,42 @@ const buildArchivePagesIndexEntries = (
617662
})
618663
.join('\n');
619664

665+
const buildHighlightsSitemapXml = async (con: DataSource): Promise<string> => {
666+
const prefix = getSitemapUrlPrefix();
667+
const queryRunner = con.createQueryRunner('slave');
668+
669+
try {
670+
const rows = await buildHighlightsSitemapQuery(
671+
queryRunner.manager,
672+
).getRawMany<{ channel: string; lastmod?: string | Date | null }>();
673+
674+
const channelEntries = rows.map((row) => ({
675+
url: getHighlightsSitemapUrl(prefix, row.channel),
676+
lastmod: getSitemapRowLastmod(row),
677+
}));
678+
const rootLastmod = channelEntries.reduce<string | undefined>(
679+
(latest, entry) => {
680+
if (!entry.lastmod) {
681+
return latest;
682+
}
683+
684+
return !latest || entry.lastmod > latest ? entry.lastmod : latest;
685+
},
686+
undefined,
687+
);
688+
689+
return getSitemapUrlSetXml([
690+
{
691+
url: getHighlightsSitemapUrl(prefix),
692+
lastmod: rootLastmod,
693+
},
694+
...channelEntries,
695+
]);
696+
} finally {
697+
await queryRunner.release();
698+
}
699+
};
700+
620701
const getSitemapIndexXml = (
621702
postsSitemapCount: number,
622703
evergreenSitemapCount: number,
@@ -645,6 +726,9 @@ ${evergreenSitemaps}
645726
<sitemap>
646727
<loc>${escapeXml(`${prefix}/api/sitemaps/collections.xml`)}</loc>
647728
</sitemap>
729+
<sitemap>
730+
<loc>${escapeXml(`${prefix}/api/sitemaps/highlights.xml`)}</loc>
731+
</sitemap>
648732
<sitemap>
649733
<loc>${escapeXml(`${prefix}/api/sitemaps/tags.xml`)}</loc>
650734
</sitemap>
@@ -785,6 +869,15 @@ export default async function (fastify: FastifyInstance): Promise<void> {
785869
);
786870
});
787871

872+
fastify.get('/highlights.xml', async (_, res) => {
873+
const con = await createOrGetConnection();
874+
875+
return res
876+
.type('application/xml')
877+
.header('cache-control', SITEMAP_CACHE_CONTROL)
878+
.send(await buildHighlightsSitemapXml(con));
879+
});
880+
788881
fastify.get('/tags.txt', async (_, res) => {
789882
const con = await createOrGetConnection();
790883
const prefix = getSitemapUrlPrefix();

0 commit comments

Comments
 (0)