1- import { Feed , type Author as FeedAuthor } from "feed" ;
1+ import { Feed , type Author as FeedAuthor , type Item } from "feed" ;
22import { sanityFetch } from "@/sanity/lib/live" ;
33import type { RssQueryResult } from "@/sanity/types" ;
4- import { rssQuery } from "@/sanity/lib/queries" ;
4+ import { rssQuery , rssPodcastQuery } from "@/sanity/lib/queries" ;
55import { toHTML } from "@portabletext/to-html" ;
66import { 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+
1327export 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, "&" )
259+ . replace ( / < / g, "<" )
260+ . replace ( / > / g, ">" )
261+ . replace ( / " / g, """ )
262+ . replace ( / ' / g, "'" ) ;
263+ }
0 commit comments