Skip to content

Commit 2be3c58

Browse files
authored
feat: Batch 3 infrastructure — sitemap, RSS, generic pages, 404 (#673)
Completes core page migration with infrastructure endpoints:\n\n- /sitemap.xml — dynamic from Sanity, all 6 content types, _updatedAt lastmod, priority tiers\n- /rss.xml — blog RSS 2.0 feed (latest 20 posts) with Atom self-link\n- /podcast/rss.xml — podcast RSS feed (latest 20 episodes)\n- /[slug] — generic Sanity pages with reserved slug guard\n- /404 — custom not found page\n\nRSS auto-discovery links added to BaseLayout head. XML-safe escaping on all text content. Cache-Control: 1 hour on all XML endpoints.\n\nThis completes the core page migration — 16 pages/endpoints across 3 batches."
1 parent 47ed2f3 commit 2be3c58

File tree

7 files changed

+239
-0
lines changed

7 files changed

+239
-0
lines changed

apps/web/src/layouts/BaseLayout.astro

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ const { title, description = "CodingCat.dev — Purrfect Web Tutorials" } = Astr
1616
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
1717
<meta name="description" content={description} />
1818
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
19+
<link rel="alternate" type="application/rss+xml" title="CodingCat.dev Blog" href="/rss.xml" />
20+
<link rel="alternate" type="application/rss+xml" title="CodingCat.dev Podcast" href="/podcast/rss.xml" />
1921
<title>{title}</title>
2022
</head>
2123
<body class="min-h-screen flex flex-col">

apps/web/src/lib/queries.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,28 @@ export const sponsorQuery = groq`*[_type == "sponsor" && slug.current == $slug][
184184
},
185185
}
186186
}`;
187+
188+
189+
// Sitemap
190+
export const sitemapQuery = groq`*[_type in ["author", "guest", "page", "podcast", "post", "sponsor"] && defined(slug.current)] | order(_type asc) {
191+
_type,
192+
_updatedAt,
193+
"slug": slug.current,
194+
}`;
195+
196+
// Generic pages (Sanity "page" type)
197+
export const pageQuery = groq`*[_type == "page" && slug.current == $slug][0] {
198+
${baseFields},
199+
${contentFields}
200+
}`;
201+
202+
// RSS feeds
203+
export const rssPostsQuery = groq`*[_type == "post" && defined(slug.current)] | order(date desc) [0...20] {
204+
${baseFields},
205+
${contentFields}
206+
}`;
207+
208+
export const rssPodcastsQuery = groq`*[_type == "podcast" && defined(slug.current)] | order(date desc) [0...20] {
209+
${baseFields},
210+
${contentFields}
211+
}`;

apps/web/src/pages/404.astro

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
import BaseLayout from "@/layouts/BaseLayout.astro";
3+
---
4+
5+
<BaseLayout title="Page Not Found — CodingCat.dev">
6+
<main class="container mx-auto px-4 py-16 text-center">
7+
<h1 class="text-6xl font-bold mb-4">404</h1>
8+
<p class="text-xl text-gray-600 mb-8">
9+
This page has gone on a catnap. 😸
10+
</p>
11+
<div class="space-x-4">
12+
<a href="/" class="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
13+
Go Home
14+
</a>
15+
<a href="/blog" class="px-6 py-3 border rounded-lg hover:bg-gray-50 transition-colors">
16+
Browse Blog
17+
</a>
18+
</div>
19+
</main>
20+
</BaseLayout>

apps/web/src/pages/[slug].astro

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
import BaseLayout from "@/layouts/BaseLayout.astro";
3+
import { PortableText } from "astro-portabletext";
4+
import { sanityFetch, urlForImage } from "@/utils/sanity";
5+
import { pageQuery } from "@/lib/queries";
6+
7+
export const prerender = false;
8+
9+
const { slug } = Astro.params;
10+
11+
// Don't catch known routes — let them fall through to their own handlers
12+
const reservedSlugs = ["blog", "podcasts", "authors", "guests", "sponsors", "login", "dashboard", "rss.xml", "sitemap.xml"];
13+
if (reservedSlugs.includes(slug!)) {
14+
return Astro.redirect(`/${slug}`);
15+
}
16+
17+
const page = await sanityFetch<any>(pageQuery, { slug });
18+
19+
if (!page) {
20+
return new Response(null, { status: 404 });
21+
}
22+
23+
const coverUrl = page.coverImage
24+
? urlForImage(page.coverImage).width(1200).height(630).format("webp").url()
25+
: null;
26+
---
27+
28+
<BaseLayout title={`${page.title} — CodingCat.dev`} description={page.excerpt}>
29+
<article class="container mx-auto px-4 py-8 max-w-4xl">
30+
<h1 class="text-4xl font-bold mb-8">{page.title}</h1>
31+
32+
{coverUrl && (
33+
<img
34+
src={coverUrl}
35+
alt={page.title}
36+
width={1200}
37+
height={630}
38+
class="w-full rounded-lg mb-8"
39+
/>
40+
)}
41+
42+
<div class="prose prose-lg max-w-none">
43+
{page.content && <PortableText value={page.content} />}
44+
</div>
45+
</article>
46+
</BaseLayout>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { APIRoute } from "astro";
2+
import { sanityFetch } from "@/utils/sanity";
3+
import { rssPodcastsQuery } from "@/lib/queries";
4+
5+
export const prerender = false;
6+
7+
function escapeXml(str: string): string {
8+
return str
9+
.replace(/&/g, "&amp;")
10+
.replace(/</g, "&lt;")
11+
.replace(/>/g, "&gt;")
12+
.replace(/"/g, "&quot;")
13+
.replace(/'/g, "&apos;");
14+
}
15+
16+
export const GET: APIRoute = async () => {
17+
const site = "https://codingcat.dev";
18+
const podcasts = await sanityFetch<any[]>(rssPodcastsQuery);
19+
20+
const items = podcasts.map((podcast) => ` <item>
21+
<title>${escapeXml(podcast.title)}</title>
22+
<link>${site}/podcast/${podcast.slug}</link>
23+
<guid>${site}/podcast/${podcast.slug}</guid>
24+
<pubDate>${new Date(podcast.date).toUTCString()}</pubDate>
25+
${podcast.excerpt ? `<description>${escapeXml(podcast.excerpt)}</description>` : ""}
26+
</item>`).join("\n");
27+
28+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
29+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
30+
<channel>
31+
<title>CodingCat.dev Podcast</title>
32+
<link>${site}/podcasts</link>
33+
<description>Purrfect Web Development Podcast</description>
34+
<language>en-us</language>
35+
<atom:link href="${site}/podcast/rss.xml" rel="self" type="application/rss+xml"/>
36+
${items}
37+
</channel>
38+
</rss>`;
39+
40+
return new Response(xml, {
41+
headers: {
42+
"Content-Type": "application/rss+xml",
43+
"Cache-Control": "public, max-age=3600",
44+
},
45+
});
46+
};

apps/web/src/pages/rss.xml.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { APIRoute } from "astro";
2+
import { sanityFetch } from "@/utils/sanity";
3+
import { rssPostsQuery } from "@/lib/queries";
4+
5+
export const prerender = false;
6+
7+
function escapeXml(str: string): string {
8+
return str
9+
.replace(/&/g, "&amp;")
10+
.replace(/</g, "&lt;")
11+
.replace(/>/g, "&gt;")
12+
.replace(/"/g, "&quot;")
13+
.replace(/'/g, "&apos;");
14+
}
15+
16+
export const GET: APIRoute = async () => {
17+
const site = "https://codingcat.dev";
18+
const posts = await sanityFetch<any[]>(rssPostsQuery);
19+
20+
const items = posts.map((post) => ` <item>
21+
<title>${escapeXml(post.title)}</title>
22+
<link>${site}/post/${post.slug}</link>
23+
<guid>${site}/post/${post.slug}</guid>
24+
<pubDate>${new Date(post.date).toUTCString()}</pubDate>
25+
${post.excerpt ? `<description>${escapeXml(post.excerpt)}</description>` : ""}
26+
</item>`).join("\n");
27+
28+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
29+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
30+
<channel>
31+
<title>CodingCat.dev Blog</title>
32+
<link>${site}/blog</link>
33+
<description>Purrfect Web Development Tutorials</description>
34+
<language>en-us</language>
35+
<atom:link href="${site}/rss.xml" rel="self" type="application/rss+xml"/>
36+
${items}
37+
</channel>
38+
</rss>`;
39+
40+
return new Response(xml, {
41+
headers: {
42+
"Content-Type": "application/rss+xml",
43+
"Cache-Control": "public, max-age=3600",
44+
},
45+
});
46+
};

apps/web/src/pages/sitemap.xml.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { APIRoute } from "astro";
2+
import { sanityFetch } from "@/utils/sanity";
3+
import { sitemapQuery } from "@/lib/queries";
4+
5+
export const prerender = false;
6+
7+
interface SitemapItem {
8+
_type: string;
9+
_updatedAt: string;
10+
slug: string;
11+
}
12+
13+
function getPath(item: SitemapItem): string {
14+
if (item._type === "page") return `/${item.slug}`;
15+
return `/${item._type}/${item.slug}`;
16+
}
17+
18+
export const GET: APIRoute = async () => {
19+
const site = "https://codingcat.dev";
20+
const items = await sanityFetch<SitemapItem[]>(sitemapQuery);
21+
22+
const staticPages = [
23+
{ url: site, lastmod: new Date().toISOString(), priority: "1.0" },
24+
{ url: `${site}/blog`, lastmod: new Date().toISOString(), priority: "0.8" },
25+
{ url: `${site}/podcasts`, lastmod: new Date().toISOString(), priority: "0.8" },
26+
{ url: `${site}/authors`, lastmod: new Date().toISOString(), priority: "0.5" },
27+
{ url: `${site}/guests`, lastmod: new Date().toISOString(), priority: "0.5" },
28+
{ url: `${site}/sponsors`, lastmod: new Date().toISOString(), priority: "0.5" },
29+
];
30+
31+
const dynamicPages = items.map((item) => ({
32+
url: `${site}${getPath(item)}`,
33+
lastmod: item._updatedAt || new Date().toISOString(),
34+
priority: item._type === "post" || item._type === "podcast" ? "0.7" : "0.5",
35+
}));
36+
37+
const allPages = [...staticPages, ...dynamicPages];
38+
39+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
40+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
41+
${allPages.map((p) => ` <url>
42+
<loc>${p.url}</loc>
43+
<lastmod>${p.lastmod}</lastmod>
44+
<priority>${p.priority}</priority>
45+
</url>`).join("\n")}
46+
</urlset>`;
47+
48+
return new Response(xml, {
49+
headers: {
50+
"Content-Type": "application/xml",
51+
"Cache-Control": "public, max-age=3600",
52+
},
53+
});
54+
};

0 commit comments

Comments
 (0)