Skip to content

Commit 93b687c

Browse files
committed
refactor: update RSS feed generation methods for podcasts and blogs
- Replaced the deprecated `buildFeed` and `buildPodcastFeed` functions with `getFeedJson` and `getPodcastFeedXml` for improved performance and clarity. - Streamlined the fetching of feed data to reduce payload size and enhance caching efficiency. - Updated related route handlers to utilize the new feed generation methods, ensuring consistent data retrieval across JSON and XML feeds.
1 parent 87ad666 commit 93b687c

7 files changed

Lines changed: 361 additions & 2551 deletions

File tree

apps/web/app/(main)/(podcast)/podcasts/rss.json/route.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import { buildFeed } from "@/lib/rss";
1+
import { getFeedJson } from "@/lib/rss";
22
import { ContentType } from "@/lib/types";
33
import { getDynamicFetchOptions } from "@/sanity/lib/live";
44

55
export async function GET() {
66
const { perspective } = await getDynamicFetchOptions();
7-
const feed = await buildFeed({
8-
type: ContentType.podcast,
9-
perspective,
10-
});
11-
return new Response(feed.json1(), {
7+
const json = await getFeedJson({ type: ContentType.podcast, perspective });
8+
return new Response(json, {
129
headers: {
1310
"content-type": "application/json",
1411
"cache-control": "max-age=0, s-maxage=3600",

apps/web/app/(main)/(podcast)/podcasts/rss.xml/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { buildPodcastFeed } from "@/lib/rss";
1+
import { getPodcastFeedXml } from "@/lib/rss";
22
import { getDynamicFetchOptions } from "@/sanity/lib/live";
33

44
export async function GET() {
55
const { perspective } = await getDynamicFetchOptions();
6-
const xml = await buildPodcastFeed({ perspective });
6+
const xml = await getPodcastFeedXml({ perspective });
77
return new Response(xml, {
88
headers: {
99
"content-type": "application/rss+xml; charset=utf-8",

apps/web/app/(main)/(post)/blog/rss.json/route.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import { buildFeed } from "@/lib/rss";
1+
import { getFeedJson } from "@/lib/rss";
22
import { ContentType } from "@/lib/types";
33
import { getDynamicFetchOptions } from "@/sanity/lib/live";
44

55
export async function GET() {
66
const { perspective } = await getDynamicFetchOptions();
7-
const feed = await buildFeed({
8-
type: ContentType.post,
9-
perspective,
10-
});
11-
return new Response(feed.json1(), {
7+
const json = await getFeedJson({ type: ContentType.post, perspective });
8+
return new Response(json, {
129
headers: {
1310
"content-type": "application/json",
1411
"cache-control": "max-age=0, s-maxage=3600",

apps/web/app/(main)/(post)/blog/rss.xml/route.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import { buildFeed } from "@/lib/rss";
1+
import { getFeedRss2 } from "@/lib/rss";
22
import { ContentType } from "@/lib/types";
33
import { getDynamicFetchOptions } from "@/sanity/lib/live";
44

55
export async function GET() {
66
const { perspective } = await getDynamicFetchOptions();
7-
const feed = await buildFeed({
8-
type: ContentType.post,
9-
perspective,
10-
});
11-
return new Response(feed.rss2(), {
7+
const rss = await getFeedRss2({ type: ContentType.post, perspective });
8+
return new Response(rss, {
129
headers: {
1310
"content-type": "application/rss+xml; charset=utf-8",
1411
"cache-control": "max-age=0, s-maxage=3600",

apps/web/lib/rss.ts

Lines changed: 68 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Feed, type Author as FeedAuthor, type Item } from "feed";
1+
import { Feed, type Item } from "feed";
22
import { sanityFetch } from "@/sanity/lib/live";
33
import type { LivePerspective } from "next-sanity/live";
44
import type { RssQueryResult } from "@/sanity/types";
@@ -12,24 +12,11 @@ const site = productionDomain
1212
: "https://codingcat.dev";
1313

1414
/**
15-
* `sanityFetch` calls `cacheTag()` internally, which is only allowed inside a
16-
* `'use cache'` function — so the data fetch must live in its own cached helper.
17-
* Route handlers hardcode `stega: false`; only `perspective` is resolved.
15+
* Feeds only ever surface the most recent entries. Pulling the entire archive
16+
* (the previous `10000`) meant every cache miss fetched and serialized every
17+
* post/podcast — expensive on both the Sanity side and in `toHTML`.
1818
*/
19-
async function fetchRssData(
20-
query: typeof rssQuery | typeof rssPodcastQuery,
21-
queryParams: { type: string; skip: string; limit: number; offset: number },
22-
perspective: LivePerspective,
23-
): Promise<RssQueryResult> {
24-
"use cache";
25-
const { data } = await sanityFetch({
26-
query,
27-
params: queryParams,
28-
perspective,
29-
stega: false,
30-
});
31-
return data as RssQueryResult;
32-
}
19+
const DEFAULT_FEED_LIMIT = 50;
3320

3421
/** Map Sanity _type to the URL path segment used on the site */
3522
function typePath(type: string): string {
@@ -43,33 +30,40 @@ function typePath(type: string): string {
4330
}
4431
}
4532

46-
export async function buildFeed(params: {
47-
type: string;
48-
skip?: string;
49-
limit?: number;
50-
offset?: number;
51-
perspective: LivePerspective;
52-
}) {
53-
const isPodcast = params.type === "podcast";
54-
const query = isPodcast ? rssPodcastQuery : rssQuery;
55-
56-
const data = await fetchRssData(
33+
/**
34+
* Fetches feed data. Called only from within a `'use cache'` boundary so the
35+
* sync tags `sanityFetch` attaches propagate to the surrounding cache entry —
36+
* the serialized feed below is then cached and revalidated by Sanity Live when
37+
* the underlying content changes, instead of being re-serialized per request.
38+
*/
39+
async function fetchFeedData(
40+
type: string,
41+
perspective: LivePerspective,
42+
): Promise<RssQueryResult> {
43+
const query = type === "podcast" ? rssPodcastQuery : rssQuery;
44+
const { data } = await sanityFetch({
5745
query,
58-
{
59-
type: params.type,
60-
skip: params.skip || "none",
61-
limit: params.limit || 10000,
62-
offset: params.offset || 0,
46+
params: {
47+
type,
48+
skip: "none",
49+
limit: DEFAULT_FEED_LIMIT,
50+
offset: 0,
6351
},
64-
params.perspective,
65-
);
52+
perspective,
53+
stega: false,
54+
});
55+
return data as RssQueryResult;
56+
}
6657

67-
const feedPath = typePath(params.type);
58+
/** Build a `feed` library `Feed` from already-fetched data (no I/O). */
59+
function createFeed(data: RssQueryResult, type: string): Feed {
60+
const feedPath = typePath(type);
6861
const currentYear = new Date().getFullYear();
62+
const isPodcast = type === "podcast";
6963

7064
const feed = new Feed({
71-
title: `CodingCat.dev - ${params.type} feed`,
72-
description: `CodingCat.dev - ${params.type} feed`,
65+
title: `CodingCat.dev - ${type} feed`,
66+
description: `CodingCat.dev - ${type} feed`,
7367
id: `${site}`,
7468
link: `${site}/${feedPath}`,
7569
language: "en",
@@ -142,40 +136,56 @@ export async function buildFeed(params: {
142136
}
143137

144138
/**
145-
* Build a podcast-specific RSS feed with iTunes namespace tags.
146-
* Returns raw XML string with proper iTunes/podcast namespace support.
139+
* Cached RSS 2.0 string for the `feed`-library feeds (blog).
140+
* The `sanityFetch` runs inside this `'use cache'` scope so the rendered XML is
141+
* cached and tagged for on-demand revalidation via Sanity Live.
147142
*/
148-
export async function buildPodcastFeed(params: {
149-
skip?: string;
150-
limit?: number;
151-
offset?: number;
143+
export async function getFeedRss2(params: {
144+
type: string;
152145
perspective: LivePerspective;
153146
}): Promise<string> {
154-
const data = await fetchRssData(
155-
rssPodcastQuery,
156-
{
157-
type: "podcast",
158-
skip: params.skip || "none",
159-
limit: params.limit || 10000,
160-
offset: params.offset || 0,
161-
},
162-
params.perspective,
163-
);
147+
"use cache";
148+
const data = await fetchFeedData(params.type, params.perspective);
149+
return createFeed(data, params.type).rss2();
150+
}
151+
152+
/** Cached JSON Feed string for the `feed`-library feeds (blog + podcast). */
153+
export async function getFeedJson(params: {
154+
type: string;
155+
perspective: LivePerspective;
156+
}): Promise<string> {
157+
"use cache";
158+
const data = await fetchFeedData(params.type, params.perspective);
159+
return createFeed(data, params.type).json1();
160+
}
161+
162+
/**
163+
* Cached podcast RSS string with the full iTunes namespace. Manually serialized
164+
* XML, produced inside `'use cache'` so it isn't rebuilt on every request.
165+
*/
166+
export async function getPodcastFeedXml(params: {
167+
perspective: LivePerspective;
168+
}): Promise<string> {
169+
"use cache";
170+
const data = await fetchFeedData("podcast", params.perspective);
171+
return buildPodcastXml(data);
172+
}
164173

174+
/** Serialize podcast data into RSS 2.0 XML with iTunes namespace tags. */
175+
function buildPodcastXml(data: RssQueryResult): string {
165176
const currentYear = new Date().getFullYear();
166177
const feedUrl = `${site}/podcasts/rss.xml`;
167178
const feedImage = `${site}/icon.svg`;
168179

169-
// Build RSS 2.0 XML with iTunes namespace manually for full podcast support
170180
const items = data
171181
.map((item) => {
172182
const imageUrl =
173-
urlForImage(item.coverImage)?.width(1400).height(1400).url() || feedImage;
183+
urlForImage(item.coverImage)?.width(1400).height(1400).url() ||
184+
feedImage;
174185
const pubDate = item.date
175186
? new Date(item.date).toUTCString()
176187
: new Date().toUTCString();
177188
const link = `${site}/${item._type}/${item.slug}`;
178-
const description = escapeXml(item.excerpt || "");
179189
const title = escapeXml(item.title || "");
180190

181191
let enclosureXml = "";

apps/web/sanity/lib/queries.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -247,15 +247,41 @@ export const sponsorQueryWithRelated = groq`*[_type == "sponsor" && slug.current
247247

248248
// RSS
249249

250+
// Trimmed field set for feeds. Only the fields actually serialized into RSS/JSON
251+
// feeds — keeps the cached payload small and avoids dereferencing data (sponsors,
252+
// tags, related video metadata) that never appears in a feed.
253+
const rssContentFields = `
254+
content[]{
255+
...,
256+
markDefs[]{
257+
...,
258+
_type == "internalLink" => {
259+
@.reference->_type == "page" => {
260+
"href": "/" + @.reference->slug.current
261+
},
262+
@.reference->_type != "page" => {
263+
"href": "/" + @.reference->_type + "/" + @.reference->slug.current
264+
}
265+
},
266+
}
267+
},
268+
author[]->{
269+
"title": coalesce(title, "Anonymous"),
270+
"slug": slug.current,
271+
}
272+
`;
273+
250274
export const rssQuery = groq`*[_type == $type && _id != $skip && defined(slug.current)] | order(date desc) [$offset...$limit] {
251275
${baseFieldsNoContent},
252-
${contentFields},
276+
${rssContentFields},
253277
}`;
254278

255279
export const rssPodcastQuery = groq`*[_type == "podcast" && _id != $skip && defined(slug.current)] | order(date desc) [$offset...$limit] {
256280
${baseFieldsNoContent},
257-
${contentFields},
258-
${podcastFields},
281+
${rssContentFields},
282+
season,
283+
episode,
284+
spotify,
259285
}`;
260286

261287
// Sitemaps

0 commit comments

Comments
 (0)