Skip to content

Commit 2fb2483

Browse files
authored
Add Google News sitemap for media news (#1383)
* Add Google News sitemap for media news * Keep Google News sitemap populated between releases
1 parent a73b551 commit 2fb2483

4 files changed

Lines changed: 246 additions & 1 deletion

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("server-only", () => ({}));
4+
5+
vi.mock("@/lib/reader", () => ({
6+
reader: {
7+
collections: {
8+
posts: {
9+
list: vi.fn(),
10+
read: vi.fn(),
11+
},
12+
},
13+
},
14+
}));
15+
16+
import { reader } from "@/lib/reader";
17+
import { getGoogleNewsSitemapResponse } from "@/lib/google-news-sitemap";
18+
19+
const mockReader = reader as any;
20+
21+
describe("getGoogleNewsSitemapResponse", () => {
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
});
25+
26+
it("includes only published posts from the last 48 hours", async () => {
27+
mockReader.collections.posts.list.mockResolvedValue([
28+
"fresh-post",
29+
"stale-post",
30+
"draft-post",
31+
"future-post",
32+
]);
33+
34+
mockReader.collections.posts.read.mockImplementation((slug: string) => {
35+
switch (slug) {
36+
case "fresh-post":
37+
return Promise.resolve({
38+
status: "published",
39+
title: "Fresh Solana News",
40+
publishedAt: "2026-04-13T13:00:00.000Z",
41+
});
42+
case "stale-post":
43+
return Promise.resolve({
44+
status: "published",
45+
title: "Old Solana News",
46+
publishedAt: "2026-04-11T11:59:59.000Z",
47+
});
48+
case "draft-post":
49+
return Promise.resolve({
50+
status: "draft",
51+
title: "Draft Solana News",
52+
publishedAt: "2026-04-14T08:00:00.000Z",
53+
});
54+
case "future-post":
55+
return Promise.resolve({
56+
status: "published",
57+
title: "Future Solana News",
58+
publishedAt: "2026-04-15T08:00:00.000Z",
59+
});
60+
default:
61+
return Promise.resolve(null);
62+
}
63+
});
64+
65+
const response = await getGoogleNewsSitemapResponse(
66+
new Date("2026-04-14T12:00:00.000Z"),
67+
);
68+
const xml = await response.text();
69+
70+
expect(response.status).toBe(200);
71+
expect(response.headers.get("Content-Type")).toContain("application/xml");
72+
expect(xml).toContain(
73+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">',
74+
);
75+
expect(xml).toContain("<loc>https://solana.com/news/fresh-post</loc>");
76+
expect(xml).toContain("<news:name>Solana</news:name>");
77+
expect(xml).toContain("<news:language>en</news:language>");
78+
expect(xml).toContain(
79+
"<news:publication_date>2026-04-13T13:00:00.000Z</news:publication_date>",
80+
);
81+
expect(xml).toContain("<news:title>Fresh Solana News</news:title>");
82+
expect(xml).toContain("<loc>https://solana.com/news/stale-post</loc>");
83+
expect(xml).toContain("<lastmod>2026-04-11T11:59:59.000Z</lastmod>");
84+
expect(xml).not.toContain("<news:title>Old Solana News</news:title>");
85+
expect(xml).not.toContain("draft-post");
86+
expect(xml).not.toContain("future-post");
87+
});
88+
89+
it("escapes XML-sensitive characters in titles", async () => {
90+
mockReader.collections.posts.list.mockResolvedValue(["escaped-post"]);
91+
mockReader.collections.posts.read.mockResolvedValue({
92+
status: "published",
93+
title: 'AT&T <Solana> "News"',
94+
publishedAt: "2026-04-14T10:00:00.000Z",
95+
});
96+
97+
const response = await getGoogleNewsSitemapResponse(
98+
new Date("2026-04-14T12:00:00.000Z"),
99+
);
100+
const xml = await response.text();
101+
102+
expect(xml).toContain(
103+
"<news:title>AT&amp;T &lt;Solana&gt; &quot;News&quot;</news:title>",
104+
);
105+
});
106+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { getGoogleNewsSitemapResponse } from "@/lib/google-news-sitemap";
2+
3+
export const revalidate = 300;
4+
5+
export async function GET() {
6+
return getGoogleNewsSitemapResponse();
7+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { NextResponse } from "next/server";
2+
import { reader } from "@/lib/reader";
3+
import { isPublishedPost } from "@/lib/keystatic/post-status";
4+
import { parsePublishedAt } from "@/lib/keystatic/publishing";
5+
6+
const BASE_URL = "https://solana.com";
7+
const NEWS_URL = `${BASE_URL}/news`;
8+
const GOOGLE_NEWS_SITEMAP_CANONICAL_PATH = "/news/google-news.xml";
9+
const PUBLICATION_NAME = "Solana";
10+
const PUBLICATION_LANGUAGE = "en";
11+
const GOOGLE_NEWS_LOOKBACK_HOURS = 48;
12+
const MAX_NEWS_URLS = 1000;
13+
const XML_CONTENT_TYPE = "application/xml; charset=utf-8";
14+
const XML_CACHE_CONTROL = "public, s-maxage=300, stale-while-revalidate=600";
15+
16+
export const GOOGLE_NEWS_SITEMAP_CANONICAL_URL = `${BASE_URL}${GOOGLE_NEWS_SITEMAP_CANONICAL_PATH}`;
17+
18+
function escapeXml(value: string): string {
19+
return value
20+
.replace(/&/g, "&amp;")
21+
.replace(/</g, "&lt;")
22+
.replace(/>/g, "&gt;")
23+
.replace(/"/g, "&quot;")
24+
.replace(/'/g, "&apos;");
25+
}
26+
27+
type RecentPublishedPost = {
28+
slug: string;
29+
title: string;
30+
publishedAt: Date;
31+
};
32+
33+
async function getPublishedPosts(): Promise<RecentPublishedPost[]> {
34+
const allSlugs = await reader.collections.posts.list();
35+
const posts: RecentPublishedPost[] = [];
36+
37+
for (const slug of allSlugs) {
38+
const post = await reader.collections.posts.read(slug);
39+
if (!isPublishedPost(post)) {
40+
continue;
41+
}
42+
43+
const publishedAt = parsePublishedAt(post.publishedAt);
44+
if (!publishedAt) {
45+
continue;
46+
}
47+
48+
posts.push({
49+
slug,
50+
title: String(post.title || slug),
51+
publishedAt,
52+
});
53+
}
54+
55+
posts.sort(
56+
(left, right) => right.publishedAt.getTime() - left.publishedAt.getTime(),
57+
);
58+
59+
return posts.slice(0, MAX_NEWS_URLS);
60+
}
61+
62+
async function buildGoogleNewsSitemapXml(
63+
now: Date = new Date(),
64+
): Promise<string> {
65+
const minNewsPublishedAt = new Date(
66+
now.getTime() - GOOGLE_NEWS_LOOKBACK_HOURS * 60 * 60 * 1000,
67+
);
68+
const posts = await getPublishedPosts();
69+
const urls = posts.map((post) => {
70+
const loc = `${NEWS_URL}/${post.slug}`;
71+
const isGoogleNewsEligible =
72+
post.publishedAt >= minNewsPublishedAt && post.publishedAt <= now;
73+
74+
const lines = [
75+
"<url>",
76+
`<loc>${escapeXml(loc)}</loc>`,
77+
`<lastmod>${escapeXml(post.publishedAt.toISOString())}</lastmod>`,
78+
];
79+
80+
if (isGoogleNewsEligible) {
81+
lines.push(
82+
"<news:news>",
83+
"<news:publication>",
84+
`<news:name>${escapeXml(PUBLICATION_NAME)}</news:name>`,
85+
`<news:language>${escapeXml(PUBLICATION_LANGUAGE)}</news:language>`,
86+
"</news:publication>",
87+
`<news:publication_date>${escapeXml(post.publishedAt.toISOString())}</news:publication_date>`,
88+
`<news:title>${escapeXml(post.title)}</news:title>`,
89+
"</news:news>",
90+
);
91+
}
92+
93+
lines.push("</url>");
94+
95+
return lines.join("");
96+
});
97+
98+
return [
99+
'<?xml version="1.0" encoding="UTF-8"?>',
100+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">',
101+
...urls,
102+
"</urlset>",
103+
"",
104+
].join("\n");
105+
}
106+
107+
export async function getGoogleNewsSitemapResponse(
108+
now: Date = new Date(),
109+
): Promise<NextResponse> {
110+
try {
111+
const xml = await buildGoogleNewsSitemapXml(now);
112+
113+
return new NextResponse(xml, {
114+
status: 200,
115+
headers: {
116+
"Content-Type": XML_CONTENT_TYPE,
117+
"Cache-Control": XML_CACHE_CONTROL,
118+
},
119+
});
120+
} catch (error) {
121+
console.error("Error generating Google News sitemap:", error);
122+
return new NextResponse("Error generating Google News sitemap", {
123+
status: 500,
124+
headers: {
125+
"Content-Type": "text/plain; charset=utf-8",
126+
},
127+
});
128+
}
129+
}

apps/web/src/app/robots.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export default function robots(): MetadataRoute.Robots {
88
userAgent: "*",
99
allow: "/",
1010
},
11-
sitemap: "https://solana.com/sitemap.xml",
11+
sitemap: [
12+
"https://solana.com/sitemap.xml",
13+
"https://solana.com/news/google-news.xml",
14+
],
1215
};
1316
}

0 commit comments

Comments
 (0)