Skip to content

Commit 7d88229

Browse files
codercatdevMiriadbuilder
authored
fix: RSS feeds, runtime Cloudinary cleanup, dead code removal (#600)
* fix: migration script downloads only originals, strips transformation params - Add raw Cloudinary object detection (old-format docs without _type) - Add stripTransformations() to remove Cloudinary URL params - Add getOriginalUrl() to construct canonical URLs from public_id - Prevents uploading derived variants (avif, webp, resized copies) Re-run results: 433 clean originals uploaded (down from 6,970 with variants) Co-authored-by: builder <builder@miriad.systems> * chore: add orphan asset cleanup script Deletes unreferenced Sanity assets left over from migration. Safety: checks document references before deleting, preserves all active assets. Supports --dry-run mode. Co-authored-by: builder <builder@miriad.systems> * fix: RSS feed improvements — podcast iTunes support, proper enclosures, content-type headers - Add full iTunes namespace to podcast feed (itunes:author, itunes:image, itunes:category, itunes:season, itunes:episode, enclosure tags) - Create buildPodcastFeed() with hand-crafted XML for Apple Podcasts compatibility - Add rssPodcastQuery with podcastFields (spotify, season, episode, guest) - Fix hardcoded Cloudinary image URL in feed channel - Fix content-type headers: text/xml → application/rss+xml - Fix feed links to be content-type-specific (blog, podcasts, courses) - Fix copyright year to be dynamic - Fix YouTube feed links pointing to non-existent routes --------- Co-authored-by: Miriad <miriad@miriad.systems> Co-authored-by: builder <builder@miriad.systems>
1 parent 4e9937c commit 7d88229

File tree

10 files changed

+1448
-40
lines changed

10 files changed

+1448
-40
lines changed

app/(main)/(course)/courses/rss.xml/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export async function GET() {
99
});
1010
return new Response(feed.rss2(), {
1111
headers: {
12-
"content-type": "text/xml",
12+
"content-type": "application/rss+xml; charset=utf-8",
1313
"cache-control": "max-age=0, s-maxage=3600",
1414
},
1515
});

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
export const dynamic = "force-dynamic"; // defaults to auto
22

3-
import { buildFeed } from "@/lib/rss";
4-
import { ContentType } from "@/lib/types";
3+
import { buildPodcastFeed } from "@/lib/rss";
54

65
export async function GET() {
7-
const feed = await buildFeed({
8-
type: ContentType.podcast,
9-
});
10-
return new Response(feed.rss2(), {
6+
const xml = await buildPodcastFeed({});
7+
return new Response(xml, {
118
headers: {
12-
"content-type": "text/xml",
9+
"content-type": "application/rss+xml; charset=utf-8",
1310
"cache-control": "max-age=0, s-maxage=3600",
1411
},
1512
});

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export async function GET() {
99
});
1010
return new Response(feed.rss2(), {
1111
headers: {
12-
"content-type": "text/xml",
12+
"content-type": "application/rss+xml; charset=utf-8",
1313
"cache-control": "max-age=0, s-maxage=3600",
1414
},
1515
});

app/api/youtube/rss.xml/route.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ export async function GET() {
6363
updated: new Date(),
6464
generator: "Next.js using Feed for Node.js",
6565
feedLinks: {
66-
json: `${process.env.NEXT_PUBLIC_BASE_URL}/api/podcast-feed`,
67-
atom: `${process.env.NEXT_PUBLIC_BASE_URL}/api/podcast-feed?format=atom`,
66+
rss2: `${process.env.NEXT_PUBLIC_BASE_URL || "https://codingcat.dev"}/api/youtube/rss.xml`,
6867
},
6968
});
7069

@@ -93,7 +92,7 @@ export async function GET() {
9392

9493
return new Response(feed.rss2(), {
9594
headers: {
96-
"content-type": "text/xml",
95+
"content-type": "application/rss+xml; charset=utf-8",
9796
"cache-control": "max-age=0, s-maxage=3600",
9897
},
9998
});

lib/rss.ts

Lines changed: 201 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Feed, type Author as FeedAuthor } from "feed";
1+
import { Feed, type Author as FeedAuthor, type Item } from "feed";
22
import { sanityFetch } from "@/sanity/lib/live";
33
import type { RssQueryResult } from "@/sanity/types";
4-
import { rssQuery } from "@/sanity/lib/queries";
4+
import { rssQuery, rssPodcastQuery } from "@/sanity/lib/queries";
55
import { toHTML } from "@portabletext/to-html";
66
import { urlForImage } from "@/sanity/lib/utils";
77

@@ -10,15 +10,32 @@ const site = productionDomain
1010
? `https://${productionDomain}`
1111
: "https://codingcat.dev";
1212

13+
/** Map Sanity _type to the URL path segment used on the site */
14+
function typePath(type: string): string {
15+
switch (type) {
16+
case "post":
17+
return "blog";
18+
case "podcast":
19+
return "podcasts";
20+
case "course":
21+
return "courses";
22+
default:
23+
return type + "s";
24+
}
25+
}
26+
1327
export async function buildFeed(params: {
1428
type: string;
1529
skip?: string;
1630
limit?: number;
1731
offset?: number;
1832
}) {
33+
const isPodcast = params.type === "podcast";
34+
const query = isPodcast ? rssPodcastQuery : rssQuery;
35+
1936
const data = (
2037
await sanityFetch({
21-
query: rssQuery,
38+
query,
2239
params: {
2340
type: params.type,
2441
skip: params.skip || "none",
@@ -28,19 +45,22 @@ export async function buildFeed(params: {
2845
})
2946
).data as RssQueryResult;
3047

48+
const feedPath = typePath(params.type);
49+
const currentYear = new Date().getFullYear();
50+
3151
const feed = new Feed({
32-
title: `${site} - ${params.type} feed`,
33-
description: `${site} - ${params.type} feed`,
52+
title: `CodingCat.dev - ${params.type} feed`,
53+
description: `CodingCat.dev - ${params.type} feed`,
3454
id: `${site}`,
35-
link: `${site}`,
36-
language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
37-
image:
38-
"https://media.codingcat.dev/image/upload/f_png,c_thumb,g_face,w_1200,h_630/dev-codingcatdev-photo/v60h88eohd7ufghkspgo.png",
55+
link: `${site}/${feedPath}`,
56+
language: "en",
57+
image: `${site}/icon.svg`,
3958
favicon: `${site}/favicon.ico`,
40-
copyright: `All rights reserved 2021, ${site}`,
59+
copyright: `All rights reserved ${currentYear}, CodingCat.dev`,
4160
updated: new Date(),
4261
feedLinks: {
43-
rss2: `${site}/blog/rss.xml`,
62+
rss2: `${site}/${feedPath}/rss.xml`,
63+
json: `${site}/${feedPath}/rss.json`,
4464
},
4565
author: {
4666
name: "Alex Patterson",
@@ -50,13 +70,16 @@ export async function buildFeed(params: {
5070
});
5171

5272
for (const item of data) {
53-
feed.addItem({
73+
const imageUrl =
74+
urlForImage(item.coverImage)?.width(1200).height(630).url() || undefined;
75+
76+
const feedItem: Item = {
5477
title: item.title || "",
5578
content:
5679
item.content && Array.isArray(item.content) ? toHTML(item.content) : "",
5780
link: `${site}/${item._type}/${item.slug}`,
58-
description: `${item.excerpt}`,
59-
image: urlForImage(item.coverImage)?.width(1200).height(630).url() || feed.items.at(0)?.image,
81+
description: item.excerpt || "",
82+
image: imageUrl,
6083
date: item.date ? new Date(item.date) : new Date(),
6184
id: item._id,
6285
author: item.author
@@ -71,7 +94,170 @@ export async function buildFeed(params: {
7194
link: `${site}/author/alex-patterson`,
7295
},
7396
],
74-
});
97+
};
98+
99+
// Add podcast enclosure from Spotify RSS data if available
100+
if (isPodcast && "spotify" in item && (item as any).spotify) {
101+
const spotify = (item as any).spotify;
102+
const enclosures = spotify.enclosures;
103+
if (Array.isArray(enclosures) && enclosures.length > 0) {
104+
const enc = enclosures[0];
105+
if (enc.url) {
106+
feedItem.enclosure = {
107+
url: enc.url,
108+
length: enc.length || 0,
109+
type: enc.type || "audio/mpeg",
110+
};
111+
}
112+
}
113+
// Add audio URL as fallback if no enclosure but link exists
114+
if (!feedItem.enclosure && spotify.link) {
115+
feedItem.audio = spotify.link;
116+
}
117+
}
118+
119+
feed.addItem(feedItem);
75120
}
121+
76122
return feed;
77123
}
124+
125+
/**
126+
* Build a podcast-specific RSS feed with iTunes namespace tags.
127+
* Returns raw XML string with proper iTunes/podcast namespace support.
128+
*/
129+
export async function buildPodcastFeed(params: {
130+
skip?: string;
131+
limit?: number;
132+
offset?: number;
133+
}): Promise<string> {
134+
const data = (
135+
await sanityFetch({
136+
query: rssPodcastQuery,
137+
params: {
138+
type: "podcast",
139+
skip: params.skip || "none",
140+
limit: params.limit || 10000,
141+
offset: params.offset || 0,
142+
},
143+
})
144+
).data as RssQueryResult;
145+
146+
const currentYear = new Date().getFullYear();
147+
const feedUrl = `${site}/podcasts/rss.xml`;
148+
const feedImage = `${site}/icon.svg`;
149+
150+
// Build RSS 2.0 XML with iTunes namespace manually for full podcast support
151+
const items = data
152+
.map((item) => {
153+
const imageUrl =
154+
urlForImage(item.coverImage)?.width(1400).height(1400).url() || feedImage;
155+
const pubDate = item.date
156+
? new Date(item.date).toUTCString()
157+
: new Date().toUTCString();
158+
const link = `${site}/${item._type}/${item.slug}`;
159+
const description = escapeXml(item.excerpt || "");
160+
const title = escapeXml(item.title || "");
161+
162+
let enclosureXml = "";
163+
let itunesXml = "";
164+
let itunesDuration = "";
165+
let itunesSeason = "";
166+
let itunesEpisode = "";
167+
let itunesEpisodeType = "full";
168+
169+
// Extract podcast-specific fields
170+
const podcastItem = item as any;
171+
if (podcastItem.spotify) {
172+
const spotify = podcastItem.spotify;
173+
if (
174+
Array.isArray(spotify.enclosures) &&
175+
spotify.enclosures.length > 0
176+
) {
177+
const enc = spotify.enclosures[0];
178+
if (enc.url) {
179+
enclosureXml = `<enclosure url="${escapeXml(enc.url)}" length="${enc.length || 0}" type="${escapeXml(enc.type || "audio/mpeg")}" />\n `;
180+
}
181+
}
182+
if (spotify.itunes) {
183+
const it = spotify.itunes;
184+
if (it.duration) itunesDuration = it.duration;
185+
if (it.episodeType) itunesEpisodeType = it.episodeType;
186+
if (it.explicit)
187+
itunesXml += `\n <itunes:explicit>${escapeXml(it.explicit)}</itunes:explicit>`;
188+
if (it.summary)
189+
itunesXml += `\n <itunes:summary>${escapeXml(it.summary)}</itunes:summary>`;
190+
if (it.image?.href)
191+
itunesXml += `\n <itunes:image href="${escapeXml(it.image.href)}" />`;
192+
}
193+
}
194+
195+
if (podcastItem.season) {
196+
itunesSeason = `\n <itunes:season>${podcastItem.season}</itunes:season>`;
197+
}
198+
if (podcastItem.episode) {
199+
itunesEpisode = `\n <itunes:episode>${podcastItem.episode}</itunes:episode>`;
200+
}
201+
202+
const authors = item.author
203+
? item.author.map((a) => a.title).join(", ")
204+
: "Alex Patterson";
205+
206+
return ` <item>
207+
<title>${title}</title>
208+
<link>${link}</link>
209+
<guid isPermaLink="false">${item._id}</guid>
210+
<pubDate>${pubDate}</pubDate>
211+
<description><![CDATA[${item.excerpt || ""}]]></description>
212+
<author>${escapeXml(authors)}</author>
213+
${enclosureXml}<itunes:title>${title}</itunes:title>
214+
<itunes:author>${escapeXml(authors)}</itunes:author>
215+
<itunes:image href="${escapeXml(imageUrl)}" />${itunesSeason}${itunesEpisode}
216+
<itunes:episodeType>${itunesEpisodeType}</itunes:episodeType>${itunesDuration ? `\n <itunes:duration>${escapeXml(itunesDuration)}</itunes:duration>` : ""}${itunesXml}
217+
</item>`;
218+
})
219+
.join("\n");
220+
221+
const lastBuildDate = new Date().toUTCString();
222+
223+
return `<?xml version="1.0" encoding="utf-8"?>
224+
<rss version="2.0"
225+
xmlns:atom="http://www.w3.org/2005/Atom"
226+
xmlns:itunes="http://www.itunes.apple.com/dtds/podcast-1.0.dtd"
227+
xmlns:content="http://purl.org/rss/1.0/modules/content/"
228+
xmlns:podcast="https://podcastindex.org/namespace/1.0">
229+
<channel>
230+
<title>CodingCat.dev Podcast</title>
231+
<link>${site}/podcasts</link>
232+
<description>The CodingCat.dev Podcast features conversations about web development, design, and technology with industry experts and community members.</description>
233+
<language>en</language>
234+
<lastBuildDate>${lastBuildDate}</lastBuildDate>
235+
<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />
236+
<copyright>All rights reserved ${currentYear}, CodingCat.dev</copyright>
237+
<itunes:author>Alex Patterson</itunes:author>
238+
<itunes:owner>
239+
<itunes:name>Alex Patterson</itunes:name>
240+
<itunes:email>alex@codingcat.dev</itunes:email>
241+
</itunes:owner>
242+
<itunes:image href="${feedImage}" />
243+
<itunes:category text="Technology" />
244+
<itunes:explicit>false</itunes:explicit>
245+
<itunes:type>episodic</itunes:type>
246+
<image>
247+
<url>${feedImage}</url>
248+
<title>CodingCat.dev Podcast</title>
249+
<link>${site}/podcasts</link>
250+
</image>
251+
${items}
252+
</channel>
253+
</rss>`;
254+
}
255+
256+
function escapeXml(str: string): string {
257+
return str
258+
.replace(/&/g, "&amp;")
259+
.replace(/</g, "&lt;")
260+
.replace(/>/g, "&gt;")
261+
.replace(/"/g, "&quot;")
262+
.replace(/'/g, "&apos;");
263+
}

sanity/lib/queries.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,12 @@ export const rssQuery = groq`*[_type == $type && _id != $skip && defined(slug.cu
315315
${contentFields},
316316
}`;
317317

318+
export const rssPodcastQuery = groq`*[_type == "podcast" && _id != $skip && defined(slug.current)] | order(date desc) [$offset...$limit] {
319+
${baseFieldsNoContent},
320+
${contentFields},
321+
${podcastFields},
322+
}`;
323+
318324
// Sitemaps
319325
export const sitemapQuery = groq`*[_type in ["author", "course", "guest", "page", "podcast", "post", "sponsor"] && defined(slug.current)] | order(_type asc) | order(_updated desc) {
320326
_type,

0 commit comments

Comments
 (0)