Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/(main)/(course)/courses/rss.xml/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export async function GET() {
});
return new Response(feed.rss2(), {
headers: {
"content-type": "text/xml",
"content-type": "application/rss+xml; charset=utf-8",
"cache-control": "max-age=0, s-maxage=3600",
},
});
Expand Down
11 changes: 4 additions & 7 deletions app/(main)/(podcast)/podcasts/rss.xml/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
export const dynamic = "force-dynamic"; // defaults to auto

import { buildFeed } from "@/lib/rss";
import { ContentType } from "@/lib/types";
import { buildPodcastFeed } from "@/lib/rss";

export async function GET() {
const feed = await buildFeed({
type: ContentType.podcast,
});
return new Response(feed.rss2(), {
const xml = await buildPodcastFeed({});
return new Response(xml, {
headers: {
"content-type": "text/xml",
"content-type": "application/rss+xml; charset=utf-8",
"cache-control": "max-age=0, s-maxage=3600",
},
});
Expand Down
2 changes: 1 addition & 1 deletion app/(main)/(post)/blog/rss.xml/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export async function GET() {
});
return new Response(feed.rss2(), {
headers: {
"content-type": "text/xml",
"content-type": "application/rss+xml; charset=utf-8",
"cache-control": "max-age=0, s-maxage=3600",
},
});
Expand Down
5 changes: 2 additions & 3 deletions app/api/youtube/rss.xml/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ export async function GET() {
updated: new Date(),
generator: "Next.js using Feed for Node.js",
feedLinks: {
json: `${process.env.NEXT_PUBLIC_BASE_URL}/api/podcast-feed`,
atom: `${process.env.NEXT_PUBLIC_BASE_URL}/api/podcast-feed?format=atom`,
rss2: `${process.env.NEXT_PUBLIC_BASE_URL || "https://codingcat.dev"}/api/youtube/rss.xml`,
},
});

Expand Down Expand Up @@ -93,7 +92,7 @@ export async function GET() {

return new Response(feed.rss2(), {
headers: {
"content-type": "text/xml",
"content-type": "application/rss+xml; charset=utf-8",
"cache-control": "max-age=0, s-maxage=3600",
},
});
Expand Down
216 changes: 201 additions & 15 deletions lib/rss.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Feed, type Author as FeedAuthor } from "feed";
import { Feed, type Author as FeedAuthor, type Item } from "feed";
import { sanityFetch } from "@/sanity/lib/live";
import type { RssQueryResult } from "@/sanity/types";
import { rssQuery } from "@/sanity/lib/queries";
import { rssQuery, rssPodcastQuery } from "@/sanity/lib/queries";
import { toHTML } from "@portabletext/to-html";
import { urlForImage } from "@/sanity/lib/utils";

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

/** Map Sanity _type to the URL path segment used on the site */
function typePath(type: string): string {
switch (type) {
case "post":
return "blog";
case "podcast":
return "podcasts";
case "course":
return "courses";
default:
return type + "s";
}
}

export async function buildFeed(params: {
type: string;
skip?: string;
limit?: number;
offset?: number;
}) {
const isPodcast = params.type === "podcast";
const query = isPodcast ? rssPodcastQuery : rssQuery;

const data = (
await sanityFetch({
query: rssQuery,
query,
params: {
type: params.type,
skip: params.skip || "none",
Expand All @@ -28,19 +45,22 @@ export async function buildFeed(params: {
})
).data as RssQueryResult;

const feedPath = typePath(params.type);
const currentYear = new Date().getFullYear();

const feed = new Feed({
title: `${site} - ${params.type} feed`,
description: `${site} - ${params.type} feed`,
title: `CodingCat.dev - ${params.type} feed`,
description: `CodingCat.dev - ${params.type} feed`,
id: `${site}`,
link: `${site}`,
language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
image:
"https://media.codingcat.dev/image/upload/f_png,c_thumb,g_face,w_1200,h_630/dev-codingcatdev-photo/v60h88eohd7ufghkspgo.png",
link: `${site}/${feedPath}`,
language: "en",
image: `${site}/icon.svg`,
favicon: `${site}/favicon.ico`,
copyright: `All rights reserved 2021, ${site}`,
copyright: `All rights reserved ${currentYear}, CodingCat.dev`,
updated: new Date(),
feedLinks: {
rss2: `${site}/blog/rss.xml`,
rss2: `${site}/${feedPath}/rss.xml`,
json: `${site}/${feedPath}/rss.json`,
},
author: {
name: "Alex Patterson",
Expand All @@ -50,13 +70,16 @@ export async function buildFeed(params: {
});

for (const item of data) {
feed.addItem({
const imageUrl =
urlForImage(item.coverImage)?.width(1200).height(630).url() || undefined;

const feedItem: Item = {
title: item.title || "",
content:
item.content && Array.isArray(item.content) ? toHTML(item.content) : "",
link: `${site}/${item._type}/${item.slug}`,
description: `${item.excerpt}`,
image: urlForImage(item.coverImage)?.width(1200).height(630).url() || feed.items.at(0)?.image,
description: item.excerpt || "",
image: imageUrl,
date: item.date ? new Date(item.date) : new Date(),
id: item._id,
author: item.author
Expand All @@ -71,7 +94,170 @@ export async function buildFeed(params: {
link: `${site}/author/alex-patterson`,
},
],
});
};

// Add podcast enclosure from Spotify RSS data if available
if (isPodcast && "spotify" in item && (item as any).spotify) {
const spotify = (item as any).spotify;
const enclosures = spotify.enclosures;
if (Array.isArray(enclosures) && enclosures.length > 0) {
const enc = enclosures[0];
if (enc.url) {
feedItem.enclosure = {
url: enc.url,
length: enc.length || 0,
type: enc.type || "audio/mpeg",
};
}
}
// Add audio URL as fallback if no enclosure but link exists
if (!feedItem.enclosure && spotify.link) {
feedItem.audio = spotify.link;
}
}

feed.addItem(feedItem);
}

return feed;
}

/**
* Build a podcast-specific RSS feed with iTunes namespace tags.
* Returns raw XML string with proper iTunes/podcast namespace support.
*/
export async function buildPodcastFeed(params: {
skip?: string;
limit?: number;
offset?: number;
}): Promise<string> {
const data = (
await sanityFetch({
query: rssPodcastQuery,
params: {
type: "podcast",
skip: params.skip || "none",
limit: params.limit || 10000,
offset: params.offset || 0,
},
})
).data as RssQueryResult;

const currentYear = new Date().getFullYear();
const feedUrl = `${site}/podcasts/rss.xml`;
const feedImage = `${site}/icon.svg`;

// Build RSS 2.0 XML with iTunes namespace manually for full podcast support
const items = data
.map((item) => {
const imageUrl =
urlForImage(item.coverImage)?.width(1400).height(1400).url() || feedImage;
const pubDate = item.date
? new Date(item.date).toUTCString()
: new Date().toUTCString();
const link = `${site}/${item._type}/${item.slug}`;
const description = escapeXml(item.excerpt || "");
const title = escapeXml(item.title || "");

let enclosureXml = "";
let itunesXml = "";
let itunesDuration = "";
let itunesSeason = "";
let itunesEpisode = "";
let itunesEpisodeType = "full";

// Extract podcast-specific fields
const podcastItem = item as any;
if (podcastItem.spotify) {
const spotify = podcastItem.spotify;
if (
Array.isArray(spotify.enclosures) &&
spotify.enclosures.length > 0
) {
const enc = spotify.enclosures[0];
if (enc.url) {
enclosureXml = `<enclosure url="${escapeXml(enc.url)}" length="${enc.length || 0}" type="${escapeXml(enc.type || "audio/mpeg")}" />\n `;
}
}
if (spotify.itunes) {
const it = spotify.itunes;
if (it.duration) itunesDuration = it.duration;
if (it.episodeType) itunesEpisodeType = it.episodeType;
if (it.explicit)
itunesXml += `\n <itunes:explicit>${escapeXml(it.explicit)}</itunes:explicit>`;
if (it.summary)
itunesXml += `\n <itunes:summary>${escapeXml(it.summary)}</itunes:summary>`;
if (it.image?.href)
itunesXml += `\n <itunes:image href="${escapeXml(it.image.href)}" />`;
}
}

if (podcastItem.season) {
itunesSeason = `\n <itunes:season>${podcastItem.season}</itunes:season>`;
}
if (podcastItem.episode) {
itunesEpisode = `\n <itunes:episode>${podcastItem.episode}</itunes:episode>`;
}

const authors = item.author
? item.author.map((a) => a.title).join(", ")
: "Alex Patterson";

return ` <item>
<title>${title}</title>
<link>${link}</link>
<guid isPermaLink="false">${item._id}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${item.excerpt || ""}]]></description>
<author>${escapeXml(authors)}</author>
${enclosureXml}<itunes:title>${title}</itunes:title>
<itunes:author>${escapeXml(authors)}</itunes:author>
<itunes:image href="${escapeXml(imageUrl)}" />${itunesSeason}${itunesEpisode}
<itunes:episodeType>${itunesEpisodeType}</itunes:episodeType>${itunesDuration ? `\n <itunes:duration>${escapeXml(itunesDuration)}</itunes:duration>` : ""}${itunesXml}
</item>`;
})
.join("\n");

const lastBuildDate = new Date().toUTCString();

return `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:itunes="http://www.itunes.apple.com/dtds/podcast-1.0.dtd"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>CodingCat.dev Podcast</title>
<link>${site}/podcasts</link>
<description>The CodingCat.dev Podcast features conversations about web development, design, and technology with industry experts and community members.</description>
<language>en</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
<atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />
<copyright>All rights reserved ${currentYear}, CodingCat.dev</copyright>
<itunes:author>Alex Patterson</itunes:author>
<itunes:owner>
<itunes:name>Alex Patterson</itunes:name>
<itunes:email>alex@codingcat.dev</itunes:email>
</itunes:owner>
<itunes:image href="${feedImage}" />
<itunes:category text="Technology" />
<itunes:explicit>false</itunes:explicit>
<itunes:type>episodic</itunes:type>
<image>
<url>${feedImage}</url>
<title>CodingCat.dev Podcast</title>
<link>${site}/podcasts</link>
</image>
${items}
</channel>
</rss>`;
}

function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
6 changes: 6 additions & 0 deletions sanity/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,12 @@ export const rssQuery = groq`*[_type == $type && _id != $skip && defined(slug.cu
${contentFields},
}`;

export const rssPodcastQuery = groq`*[_type == "podcast" && _id != $skip && defined(slug.current)] | order(date desc) [$offset...$limit] {
${baseFieldsNoContent},
${contentFields},
${podcastFields},
}`;

// Sitemaps
export const sitemapQuery = groq`*[_type in ["author", "course", "guest", "page", "podcast", "post", "sponsor"] && defined(slug.current)] | order(_type asc) | order(_updated desc) {
_type,
Expand Down
Loading
Loading