|
| 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, "&") |
| 21 | + .replace(/</g, "<") |
| 22 | + .replace(/>/g, ">") |
| 23 | + .replace(/"/g, """) |
| 24 | + .replace(/'/g, "'"); |
| 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 | +} |
0 commit comments