Skip to content

Commit c5cc7a3

Browse files
codercatdevclaude
andcommitted
feat: feeds, sitemap, and robots
Ports /blog/rss.{xml,json}, /podcasts/rss.{xml,json}, /sitemap.xml and /robots.txt, all serialized by hand — the `feed` package reaches for Node built-ins that are awkward on workerd, and these are fixed shapes. Verified against the pre-migration production snapshots in apps/site/baseline by running the Worker with the production config: - sitemap.xml: byte-identical, all 472 URLs - podcasts/rss.xml: byte-identical apart from timestamps - blog/rss.xml: identical apart from <generator>, which no longer names a library we do not use - both JSON feeds: same key order, same 50 ids in the same order - robots.txt: identical GUID stability was the thing to get right and it holds: all 100 feed items keep their Sanity _id, in order. A changed podcast GUID would re-publish the entire back catalogue to Apple and Spotify. Two live bugs fixed rather than reproduced: - The production blog feed is not well-formed XML. The `feed` library emits unescaped `&` in enclosure URLs, so `ET.parse` rejects blog_rss.xml at line 25. Ours escapes them. - Feed bodies were serialized by `toHTML` with no component map, so internal links rendered as `<span class="unknown__pt__mark__internalLink">` and code, images, tables and embeds were dropped or stubbed. There is now a map; links resolve to absolute URLs so they work inside a reader. Deliberately reproduced, including the odd parts: `image/jpg` rather than `image/jpeg` for enclosures, one `<author>` element per author, and `<description>` omitted entirely when the excerpt is empty. Churning those would change what existing subscribers see for no benefit. robots.txt now serves `Disallow: /` outside production, so the dev Worker and preview aliases cannot be indexed alongside the real site. Also fixes a deployment trap found while verifying this. The Cloudflare adapter flattens wrangler.jsonc at BUILD time and writes .wrangler/deploy/config.json, which redirects wrangler away from the source config — `env.production` is dropped, keeping only its name. So `wrangler deploy --env production` on a default build would have shipped the dev dataset under the dev worker name. The environment is selected by `CLOUDFLARE_ENV` at build time; added a `build:production` script and documented it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
1 parent 4d5cdf4 commit c5cc7a3

12 files changed

Lines changed: 721 additions & 1 deletion

File tree

‎CLAUDE.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,29 @@ never by checking `dev` out over the working tree.
3131
frontmatter, so `defineQuery` calls in `.astro` files are silently skipped.
3232
- **Never read secrets from `import.meta.env`.** Vite inlines non-`PUBLIC_` vars
3333
into the server bundle, baking them into the deployed Worker script. Read them
34-
per-request from `locals.runtime.env`.
34+
from `import { env } from "cloudflare:workers"` — `@astrojs/cloudflare` v14
35+
reduced `Runtime` to `{ cfContext }` and deprecated `locals.runtime`.
3536
- **Cloudflare bindings are not inherited into `env.production`.** Anything added
3637
to the top level of `wrangler.jsonc` must be repeated there.
38+
- **The environment is chosen at BUILD time, via `CLOUDFLARE_ENV`.** The adapter
39+
flattens `wrangler.jsonc` into `dist/server/wrangler.json` and writes
40+
`.wrangler/deploy/config.json`, which redirects wrangler away from the source
41+
config — so `wrangler deploy --env production` on a default build silently
42+
ships the *dev* dataset under the *dev* worker name. Use
43+
`pnpm --filter @codingcatdev/site build:production`.
44+
- **`Astro.site` is baked in at build time**, so it cannot carry a per-env
45+
origin. Canonicals, `og:url`, feeds and the sitemap read `Astro.locals.siteUrl`,
46+
resolved per request from the `SITE_URL` var in middleware.
3747
- **`stegaClean` anything bound to an attribute**, `<meta>`, JSON-LD, or XML.
3848
Stega's zero-width characters are harmless in text nodes and corrupting in
3949
`href`, `src`, and feeds.
4050
- **`@sanity/icons` v5 has no root-entry icon exports.** Import from subpaths:
4151
`import {UserIcon} from "@sanity/icons/User"`.
52+
- **`astro-portabletext` uses singular `type` / `block` / `mark` keys**, and mark
53+
components read the mark definition from `node.markDef`, not `node`. Both fail
54+
silently — the component simply never runs.
55+
- **A self-closing `<script />` in an `.astro` file breaks props inference**, so
56+
`Astro.props` degrades to `Record<string, any>`. Use paired tags.
4257
- `apps/sanity/plugins/podcast-rss/` is vendored, not an npm dep — see its README.
4358

4459
## Agent skills

‎apps/site/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"scripts": {
99
"dev": "astro dev",
1010
"build": "astro build",
11+
"build:production": "CLOUDFLARE_ENV=production astro build",
1112
"preview": "astro build && wrangler dev",
1213
"check": "astro check",
1314
"typecheck": "astro check",
@@ -18,6 +19,7 @@
1819
"dependencies": {
1920
"@astrojs/cloudflare": "^14.2.1",
2021
"@astrojs/react": "^6.0.2",
22+
"@portabletext/to-html": "^6.0.0",
2123
"@portabletext/types": "^4.0.2",
2224
"@sanity/client": "^7.26.2",
2325
"@sanity/image-url": "^2.1.1",

‎apps/site/src/lib/feeds.ts‎

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import { portableTextToHtml } from "./portable-text-html";
2+
import type { SanityRequestContext } from "./sanity/context";
3+
import { SITE_NAME } from "./site";
4+
import { cdata, escapeXml, imageMimeType, rfc822 } from "./xml";
5+
6+
/**
7+
* Feed serialization.
8+
*
9+
* The output reproduces what the `feed` npm package emitted, element order and
10+
* all, because these documents are already subscribed to. In particular:
11+
*
12+
* - `<guid isPermaLink="false">` stays the Sanity `_id`. Changing a podcast
13+
* GUID makes Apple and Spotify re-publish the entire back catalogue as new
14+
* episodes; this is the single most destructive thing this file could get
15+
* wrong.
16+
* - Enclosure MIME types keep the library's `image/jpg` spelling.
17+
*
18+
* The library itself is not ported: it reaches for Node built-ins that are
19+
* awkward on workerd, and this is a fixed document shape.
20+
*/
21+
22+
/** Feeds only ever carry the most recent entries. */
23+
export const FEED_LIMIT = 50;
24+
25+
const FEED_AUTHOR = {
26+
name: "Alex Patterson",
27+
email: "alex@codingcat.dev",
28+
slug: "alex-patterson",
29+
};
30+
31+
/** Sanity `_type` to the URL segment its listing lives under. */
32+
export function typePath(type: string): string {
33+
switch (type) {
34+
case "post":
35+
return "blog";
36+
case "podcast":
37+
return "podcasts";
38+
default:
39+
return `${type}s`;
40+
}
41+
}
42+
43+
export interface FeedItemSource {
44+
_id: string;
45+
_type: string;
46+
title: string;
47+
slug: string | null;
48+
excerpt?: string | null;
49+
coverImage?: unknown;
50+
date?: string | null;
51+
content?: unknown;
52+
author?: Array<{ title: string; slug: string | null }> | null;
53+
season?: number | null;
54+
episode?: number | null;
55+
spotify?: {
56+
link?: string;
57+
enclosures?: Array<{ url?: string; length?: number; type?: string }>;
58+
itunes?: {
59+
duration?: string;
60+
episodeType?: string;
61+
explicit?: string;
62+
summary?: string;
63+
image?: { href?: string };
64+
};
65+
} | null;
66+
}
67+
68+
interface FeedContext {
69+
origin: string;
70+
sanity: SanityRequestContext;
71+
type: "post" | "podcast";
72+
items: FeedItemSource[];
73+
now: Date;
74+
}
75+
76+
function itemUrl(origin: string, item: FeedItemSource) {
77+
return `${origin}/${item._type}/${item.slug}`;
78+
}
79+
80+
function itemImage(
81+
ctx: FeedContext,
82+
item: FeedItemSource,
83+
width: number,
84+
height: number,
85+
) {
86+
return ctx.sanity
87+
.urlForImage(item.coverImage as never)
88+
?.width(width)
89+
.height(height)
90+
.url();
91+
}
92+
93+
function itemHtml(ctx: FeedContext, item: FeedItemSource) {
94+
return portableTextToHtml(item.content, {
95+
origin: ctx.origin,
96+
imageUrl: (source) =>
97+
ctx.sanity
98+
.urlForImage(source as never)
99+
?.width(1200)
100+
.url(),
101+
});
102+
}
103+
104+
function itemAuthors(item: FeedItemSource) {
105+
return item.author?.length ? item.author : null;
106+
}
107+
108+
// --- RSS 2.0 (blog) ---
109+
110+
export function buildRss2(ctx: FeedContext): string {
111+
const { origin, type, items, now } = ctx;
112+
const feedPath = typePath(type);
113+
const title = `${SITE_NAME} - ${type} feed`;
114+
const home = `${origin}/${feedPath}`;
115+
116+
const body = items
117+
.map((item) => {
118+
const image = itemImage(ctx, item, 1200, 630);
119+
const authors = itemAuthors(item) ?? [{ title: FEED_AUTHOR.name }];
120+
121+
// One element per author, and description omitted entirely when the
122+
// excerpt is empty — both reproduce the `feed` library exactly, so
123+
// existing subscribers see no change.
124+
const authorTags = authors
125+
.map(
126+
(author) =>
127+
`\n <author>${escapeXml(author.title)}</author>`,
128+
)
129+
.join("");
130+
131+
const description = item.excerpt
132+
? `\n <description>${cdata(item.excerpt)}</description>`
133+
: "";
134+
135+
const enclosure = image
136+
? `\n <enclosure url="${escapeXml(image)}" length="0" type="${imageMimeType(image)}"/>`
137+
: "";
138+
139+
return ` <item>
140+
<title>${cdata(item.title)}</title>
141+
<link>${escapeXml(itemUrl(origin, item))}</link>
142+
<guid isPermaLink="false">${escapeXml(item._id)}</guid>
143+
<pubDate>${rfc822(item.date ? new Date(item.date) : now)}</pubDate>${description}
144+
<content:encoded>${cdata(itemHtml(ctx, item))}</content:encoded>${authorTags}${enclosure}
145+
</item>`;
146+
})
147+
.join("\n");
148+
149+
return `<?xml version="1.0" encoding="utf-8"?>
150+
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
151+
<channel>
152+
<title>${escapeXml(title)}</title>
153+
<link>${escapeXml(home)}</link>
154+
<description>${escapeXml(title)}</description>
155+
<lastBuildDate>${rfc822(now)}</lastBuildDate>
156+
<docs>https://validator.w3.org/feed/docs/rss2.html</docs>
157+
<generator>${escapeXml(origin)}</generator>
158+
<language>en</language>
159+
<image>
160+
<title>${escapeXml(title)}</title>
161+
<url>${escapeXml(origin)}/icon.svg</url>
162+
<link>${escapeXml(home)}</link>
163+
</image>
164+
<copyright>All rights reserved ${now.getUTCFullYear()}, ${SITE_NAME}</copyright>
165+
${body}
166+
</channel>
167+
</rss>`;
168+
}
169+
170+
// --- JSON Feed 1.0 ---
171+
172+
export function buildJsonFeed(ctx: FeedContext): string {
173+
const { origin, type, items } = ctx;
174+
const feedPath = typePath(type);
175+
const title = `${SITE_NAME} - ${type} feed`;
176+
177+
return JSON.stringify(
178+
{
179+
version: "https://jsonfeed.org/version/1",
180+
title,
181+
home_page_url: `${origin}/${feedPath}`,
182+
feed_url: `${origin}/${feedPath}/rss.json`,
183+
description: title,
184+
icon: `${origin}/icon.svg`,
185+
author: {
186+
name: FEED_AUTHOR.name,
187+
url: origin,
188+
},
189+
items: items.map((item) => {
190+
const authors = itemAuthors(item);
191+
const primary = authors?.[0];
192+
return {
193+
id: item._id,
194+
content_html: itemHtml(ctx, item),
195+
url: itemUrl(origin, item),
196+
title: item.title,
197+
summary: item.excerpt ?? undefined,
198+
image: itemImage(ctx, item, 1200, 630),
199+
date_modified: item.date
200+
? new Date(item.date).toISOString()
201+
: undefined,
202+
author: primary
203+
? {
204+
name: primary.title,
205+
url: `${origin}/author/${primary.slug}`,
206+
}
207+
: {
208+
name: FEED_AUTHOR.name,
209+
url: `${origin}/author/${FEED_AUTHOR.slug}`,
210+
},
211+
};
212+
}),
213+
},
214+
null,
215+
2,
216+
);
217+
}
218+
219+
// --- Podcast RSS with the iTunes namespace ---
220+
221+
export function buildPodcastRss(ctx: FeedContext): string {
222+
const { origin, items, now } = ctx;
223+
const feedUrl = `${origin}/podcasts/rss.xml`;
224+
const feedImage = `${origin}/icon.svg`;
225+
226+
const body = items
227+
.map((item) => {
228+
const image = itemImage(ctx, item, 1400, 1400) ?? feedImage;
229+
const title = escapeXml(item.title);
230+
const authors = itemAuthors(item);
231+
const authorName = authors
232+
? authors.map((a) => a.title).join(", ")
233+
: FEED_AUTHOR.name;
234+
235+
const spotify = item.spotify;
236+
const enclosure = spotify?.enclosures?.[0];
237+
const enclosureXml = enclosure?.url
238+
? `<enclosure url="${escapeXml(enclosure.url)}" length="${enclosure.length ?? 0}" type="${escapeXml(enclosure.type ?? "audio/mpeg")}" />\n `
239+
: "";
240+
241+
const itunes = spotify?.itunes;
242+
let itunesXml = "";
243+
if (itunes?.explicit) {
244+
itunesXml += `\n <itunes:explicit>${escapeXml(itunes.explicit)}</itunes:explicit>`;
245+
}
246+
if (itunes?.summary) {
247+
itunesXml += `\n <itunes:summary>${escapeXml(itunes.summary)}</itunes:summary>`;
248+
}
249+
if (itunes?.image?.href) {
250+
itunesXml += `\n <itunes:image href="${escapeXml(itunes.image.href)}" />`;
251+
}
252+
253+
const season = item.season
254+
? `\n <itunes:season>${item.season}</itunes:season>`
255+
: "";
256+
const episode = item.episode
257+
? `\n <itunes:episode>${item.episode}</itunes:episode>`
258+
: "";
259+
const duration = itunes?.duration
260+
? `\n <itunes:duration>${escapeXml(itunes.duration)}</itunes:duration>`
261+
: "";
262+
263+
return ` <item>
264+
<title>${title}</title>
265+
<link>${escapeXml(itemUrl(origin, item))}</link>
266+
<guid isPermaLink="false">${escapeXml(item._id)}</guid>
267+
<pubDate>${rfc822(item.date ? new Date(item.date) : now)}</pubDate>
268+
<description>${cdata(item.excerpt ?? "")}</description>
269+
<author>${escapeXml(authorName)}</author>
270+
${enclosureXml}<itunes:title>${title}</itunes:title>
271+
<itunes:author>${escapeXml(authorName)}</itunes:author>
272+
<itunes:image href="${escapeXml(image)}" />${season}${episode}
273+
<itunes:episodeType>${escapeXml(itunes?.episodeType ?? "full")}</itunes:episodeType>${duration}${itunesXml}
274+
</item>`;
275+
})
276+
.join("\n");
277+
278+
return `<?xml version="1.0" encoding="utf-8"?>
279+
<rss version="2.0"
280+
xmlns:atom="http://www.w3.org/2005/Atom"
281+
xmlns:itunes="http://www.itunes.apple.com/dtds/podcast-1.0.dtd"
282+
xmlns:content="http://purl.org/rss/1.0/modules/content/"
283+
xmlns:podcast="https://podcastindex.org/namespace/1.0">
284+
<channel>
285+
<title>${SITE_NAME} Podcast</title>
286+
<link>${escapeXml(origin)}/podcasts</link>
287+
<description>The ${SITE_NAME} Podcast features conversations about web development, design, and technology with industry experts and community members.</description>
288+
<language>en</language>
289+
<lastBuildDate>${rfc822(now)}</lastBuildDate>
290+
<atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />
291+
<copyright>All rights reserved ${now.getUTCFullYear()}, ${SITE_NAME}</copyright>
292+
<itunes:author>${FEED_AUTHOR.name}</itunes:author>
293+
<itunes:owner>
294+
<itunes:name>${FEED_AUTHOR.name}</itunes:name>
295+
<itunes:email>${FEED_AUTHOR.email}</itunes:email>
296+
</itunes:owner>
297+
<itunes:image href="${escapeXml(feedImage)}" />
298+
<itunes:category text="Technology" />
299+
<itunes:explicit>false</itunes:explicit>
300+
<itunes:type>episodic</itunes:type>
301+
<image>
302+
<url>${escapeXml(feedImage)}</url>
303+
<title>${SITE_NAME} Podcast</title>
304+
<link>${escapeXml(origin)}/podcasts</link>
305+
</image>
306+
${body}
307+
</channel>
308+
</rss>`;
309+
}
310+
311+
export type { FeedContext };

0 commit comments

Comments
 (0)