diff --git a/package-lock.json b/package-lock.json
index b963f40..3bb95f2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery.badmanners.xyz",
-  "version": "1.8.1",
+  "version": "1.8.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery.badmanners.xyz",
-      "version": "1.8.1",
+      "version": "1.8.2",
       "hasInstallScript": true,
       "dependencies": {
         "@astrojs/check": "^0.9.3",
diff --git a/package.json b/package.json
index 36ce21e..4108151 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery.badmanners.xyz",
   "type": "module",
-  "version": "1.8.1",
+  "version": "1.8.2",
   "scripts": {
     "postinstall": "astro sync",
     "dev": "astro dev",
diff --git a/src/content/blog/taken-in-breakdown.mdx b/src/content/blog/taken-in-breakdown.mdx
index 2d4b725..2475a10 100644
--- a/src/content/blog/taken-in-breakdown.mdx
+++ b/src/content/blog/taken-in-breakdown.mdx
@@ -26,15 +26,14 @@ relatedStories:
 
 import NoteTooltip from "@components/NoteTooltip.astro";
 export let count = 0;
-export function resetCount() {
-  count = 0;
-}
 export function getCount() {
   count += 1;
   return count;
 }
 
-{resetCount()}
+{function () {
+count = 0;
+}()}
 
 Going over the story and breaking it down was a fun process, all in all. But this was originally a text document, which I had to completely manually refit into this post you're reading (oof...!). Still, for the sake of posteriority, I think it was worth the effort.
 
diff --git a/src/layouts/GalleryLayout.astro b/src/layouts/GalleryLayout.astro
index 3cabcbc..8ff39a0 100644
--- a/src/layouts/GalleryLayout.astro
+++ b/src/layouts/GalleryLayout.astro
@@ -84,7 +84,7 @@ const isCurrentRoute = (path: string) =>
         <li>
           <a class="u-url text-link group" href="/blog" aria-current={isCurrentRoute("/blog") ? "page" : undefined}>
             <IconBlog width="1.25rem" height="1.25rem" class="order-1 inline align-text-top" />
-            <span class="order-3 group-hover:underline group-focus:underline">Blog posts</span>
+            <span class="order-3 group-hover:underline group-focus:underline">Blog</span>
           </a>
         </li>
         <li>
@@ -104,12 +104,6 @@ const isCurrentRoute = (path: string) =>
             <span class="order-3 group-hover:underline group-focus:underline">Search</span>
           </a>
         </li>
-        <li>
-          <a class="u-url text-link group" href="/feed.xml">
-            <IconSquareRSS width="1.25rem" height="1.25rem" class="order-1 inline align-text-top" />
-            <span class="order-3 group-hover:underline group-focus:underline">RSS feed</span>
-          </a>
-        </li>
         <li>
           <button
             data-dark-mode
@@ -144,7 +138,7 @@ const isCurrentRoute = (path: string) =>
       </div>
     </nav>
     <main
-      class:list={[className, "ml-0 max-w-6xl px-2 pb-28 pt-4 md:px-4 lg:px-8 print:pb-0"]}
+      class:list={[className, "ml-0 w-full max-w-6xl px-2 pb-28 pt-4 md:px-4 lg:px-8 print:pb-0"]}
       data-pagefind-body={enablePagefind ? "" : undefined}
     >
       <slot />
diff --git a/src/pages/blog/feed.xml.ts b/src/pages/blog/feed.xml.ts
new file mode 100644
index 0000000..18f4f0d
--- /dev/null
+++ b/src/pages/blog/feed.xml.ts
@@ -0,0 +1,23 @@
+import rss from "@astrojs/rss";
+import type { APIRoute } from "astro";
+import { getCollection } from "astro:content";
+import { blogFeedItem, type EntryWithPubDate } from "@utils/feed";
+
+const MAX_ITEMS = 8;
+
+export const GET: APIRoute = async ({ site }) => {
+  const posts = (
+    (await getCollection("blog", (post) => !post.data.isDraft && post.data.pubDate)) as EntryWithPubDate<"blog">[]
+  )
+    .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+    .slice(0, MAX_ITEMS);
+
+  return rss({
+    title: "Blog | Bad Manners",
+    description: "Blog posts by Bad Manners.",
+    site: new URL("/blog", site!),
+    items: await Promise.all(
+      posts.map(async ({ data, slug, render }) => blogFeedItem(site, data, slug, (await render()).Content)),
+    ),
+  });
+};
diff --git a/src/pages/blog.astro b/src/pages/blog/index.astro
similarity index 88%
rename from src/pages/blog.astro
rename to src/pages/blog/index.astro
index 447684e..4e4190f 100644
--- a/src/pages/blog.astro
+++ b/src/pages/blog/index.astro
@@ -4,6 +4,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "@layouts/GalleryLayout.astro";
 import UserComponent from "@components/UserComponent.astro";
 import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
+import { IconSquareRSS } from "@components/icons";
 
 type PostWithPubDate = CollectionEntry<"blog"> & { data: { pubDate: Date } };
 
@@ -21,7 +22,13 @@ const posts = await Promise.all(
   <meta slot="head" property="og:description" content="Bad Manners || A game that I've gone and done." />
   <h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Blog</h1>
   <hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
-  <p class="p-summary my-4">Posts on whatever has been rattling in my head as of late.</p>
+  <div class="my-4 flex w-full">
+    <p class="p-summary grow">Posts on whatever has been rattling in my head as of late.</p>
+    <a class="u-url text-link ml-2 mr-10 p-2" href="/blog/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
+      <IconSquareRSS width="2rem" height="2rem" />
+      <span class="sr-only">RSS feed</span>
+    </a>
+  </div>
   <ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
       posts.map((post, i) => (
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index 9db1738..ac0da5b 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -1,139 +1,9 @@
-import rss, { type RSSFeedItem } from "@astrojs/rss";
+import rss from "@astrojs/rss";
 import type { APIRoute } from "astro";
-import { getCollection, getEntries, type CollectionEntry, type CollectionKey } from "astro:content";
-import { markdown } from "@astropub/md";
-import sanitizeHtml from "sanitize-html";
-import { t, type Lang } from "@i18n";
-import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
-import { getUsernameForLang } from "@utils/get_username_for_lang";
-import { qualifyLocalURLsInMarkdown } from "@utils/qualify_local_urls_in_markdown";
+import { getCollection } from "astro:content";
+import { blogFeedItem, gameFeedItem, storyFeedItem, type EntryWithPubDate } from "@utils/feed";
 
-type FeedItem = RSSFeedItem &
-  Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>;
-
-type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } };
-
-const MAX_ITEMS = 6;
-
-function toNoonUTCDate(date: Date) {
-  const adjustedDate = new Date(date);
-  adjustedDate.setUTCHours(12);
-  return adjustedDate;
-}
-
-const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
-  const userName = getUsernameForLang(user, lang);
-  if (user.data.preferredLink) {
-    return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`;
-  }
-  return userName;
-};
-
-async function storyFeedItem(
-  site: URL | undefined,
-  data: EntryWithPubDate<"stories">["data"],
-  slug: CollectionEntry<"stories">["slug"],
-  body: string,
-): Promise<FeedItem> {
-  return {
-    title: `New story! "${data.title}"`,
-    pubDate: toNoonUTCDate(data.pubDate),
-    link: `/stories/${slug}`,
-    description:
-      `${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
-        /[\n ]+/g,
-        " ",
-      ),
-    categories: ["story"],
-    commentsUrl: data.posts.mastodon?.link,
-    content: sanitizeHtml(
-      `<h1>${data.title}</h1>` +
-        `<p>${t(
-          data.lang,
-          "story/authors",
-          (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
-        )}</p>` +
-        (data.requesters
-          ? `<p>${t(
-              data.lang,
-              "story/requested_by",
-              (await getEntries(data.requesters)).map((requester) => getLinkForUser(requester, data.lang)),
-            )}</p>`
-          : "") +
-        (data.commissioners
-          ? `<p>${t(
-              data.lang,
-              "story/commissioned_by",
-              (await getEntries(data.commissioners)).map((commissioner) => getLinkForUser(commissioner, data.lang)),
-            )}</p>`
-          : "") +
-        `<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` +
-        `<hr>${await markdown(body)}` +
-        `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
-    ),
-  };
-}
-
-async function gameFeedItem(
-  site: URL | undefined,
-  data: EntryWithPubDate<"games">["data"],
-  slug: CollectionEntry<"games">["slug"],
-  body: string,
-): Promise<FeedItem> {
-  return {
-    title: `New game! "${data.title}"`,
-    pubDate: toNoonUTCDate(data.pubDate),
-    link: `/games/${slug}`,
-    description:
-      `${t(data.lang, "game/warnings", data.platforms, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
-        /[\n ]+/g,
-        " ",
-      ),
-    categories: ["game"],
-    commentsUrl: data.posts.mastodon?.link,
-    content: sanitizeHtml(
-      `<h1>${data.title}</h1>` +
-        `<p>${t(
-          data.lang,
-          "story/authors",
-          (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
-        )}</p>` +
-        `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
-        `<hr><p><em>${data.contentWarning}</em></p>` +
-        `<hr>${await markdown(body)}` +
-        `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
-    ),
-  };
-}
-
-async function blogFeedItem(
-  site: URL | undefined,
-  data: EntryWithPubDate<"blog">["data"],
-  slug: CollectionEntry<"blog">["slug"],
-  body: string,
-): Promise<FeedItem> {
-  return {
-    title: `New blog post! "${data.title}"`,
-    pubDate: toNoonUTCDate(data.pubDate),
-    link: `/blog/${slug}`,
-    description: markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site)).replaceAll(
-      /[\n ]+/g,
-      " ",
-    ),
-    categories: ["blog post"],
-    commentsUrl: data.posts.mastodon?.link,
-    content: sanitizeHtml(
-      `<h1>${data.title}</h1>` +
-        `<p>${t(
-          data.lang,
-          "story/authors",
-          (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
-        )}</p>` +
-        `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}` +
-        `<hr>${await markdown(body)}`,
-    ),
-  };
-}
+const MAX_ITEMS = 8;
 
 export const GET: APIRoute = async ({ site }) => {
   const stories = (
@@ -157,21 +27,21 @@ export const GET: APIRoute = async ({ site }) => {
 
   return rss({
     title: "Gallery | Bad Manners",
-    description: "Stories, games, and (possibly) more by Bad Manners",
+    description: "Stories, games, and more by Bad Manners.",
     site: site!,
     items: await Promise.all(
       [
-        stories.map(({ data, slug, body }) => ({
+        stories.map(({ data, slug, render }) => ({
           date: data.pubDate,
-          fn: () => storyFeedItem(site, data, slug, body),
+          fn: async () => storyFeedItem(site, data, slug, (await render()).Content),
         })),
-        games.map(({ data, slug, body }) => ({
+        games.map(({ data, slug, render }) => ({
           date: data.pubDate,
-          fn: () => gameFeedItem(site, data, slug, body),
+          fn: async () => gameFeedItem(site, data, slug, (await render()).Content),
         })),
-        posts.map(({ data, slug, body }) => ({
+        posts.map(({ data, slug, render }) => ({
           date: data.pubDate,
-          fn: () => blogFeedItem(site, data, slug, body),
+          fn: async () => blogFeedItem(site, data, slug, (await render()).Content),
         })),
       ]
         .flat()
diff --git a/src/pages/games/feed.xml.ts b/src/pages/games/feed.xml.ts
new file mode 100644
index 0000000..b637f94
--- /dev/null
+++ b/src/pages/games/feed.xml.ts
@@ -0,0 +1,23 @@
+import rss from "@astrojs/rss";
+import type { APIRoute } from "astro";
+import { getCollection } from "astro:content";
+import { gameFeedItem, type EntryWithPubDate } from "@utils/feed";
+
+const MAX_ITEMS = 8;
+
+export const GET: APIRoute = async ({ site }) => {
+  const stories = (
+    (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)) as EntryWithPubDate<"games">[]
+  )
+    .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+    .slice(0, MAX_ITEMS);
+
+  return rss({
+    title: "Games | Bad Manners",
+    description: "Games by Bad Manners.",
+    site: new URL("/games", site!),
+    items: await Promise.all(
+      stories.map(async ({ data, slug, render }) => gameFeedItem(site, data, slug, (await render()).Content)),
+    ),
+  });
+};
diff --git a/src/pages/games.astro b/src/pages/games/index.astro
similarity index 89%
rename from src/pages/games.astro
rename to src/pages/games/index.astro
index 4375f6a..0df3c1b 100644
--- a/src/pages/games.astro
+++ b/src/pages/games/index.astro
@@ -4,6 +4,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "@layouts/GalleryLayout.astro";
 import { t } from "@i18n";
 import UserComponent from "@components/UserComponent.astro";
+import { IconSquareRSS } from "@components/icons";
 
 type GameWithPubDate = CollectionEntry<"games"> & { data: { pubDate: Date } };
 
@@ -21,7 +22,13 @@ const games = await Promise.all(
   <meta slot="head" property="og:description" content="Bad Manners || A game that I've gone and done." />
   <h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Games</h1>
   <hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
-  <p class="p-summary my-4">A game that I've gone and done.</p>
+  <div class="my-4 flex w-full">
+    <p class="p-summary grow">A game that I've gone and done.</p>
+    <a class="u-url text-link ml-2 mr-10 p-2" href="/games/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
+      <IconSquareRSS width="2rem" height="2rem" />
+      <span class="sr-only">RSS feed</span>
+    </a>
+  </div>
   <ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
       games.map((game, i) => (
diff --git a/src/pages/index.astro b/src/pages/index.astro
index cbe7ebd..2777fa5 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -6,6 +6,7 @@ import GalleryLayout from "@layouts/GalleryLayout.astro";
 import { t, type Lang } from "@i18n";
 import UserComponent from "@components/UserComponent.astro";
 import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
+import { IconSquareRSS } from "@components/icons";
 
 const MAX_ITEMS = 10;
 
@@ -98,22 +99,17 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
   <h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Gallery</h1>
   <hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
   <div class="p-summary">
-    <p class="my-4">
-      Hey there, welcome to my corner of the Internet! You can expect lots of safe vore/endosoma ahead.
-    </p>
-    <ul class="list-disc pl-8">
-      <li><a class="text-link underline" href="/stories/1">Read my stories!</a></li>
-      <li><a class="text-link underline" href="/games/crossing-over">Play my visual novel!</a></li>
-      <li><a class="text-link underline" href="/tags">Find all content with a certain tag!</a></li>
-    </ul>
-    <p class="my-4">
-      For more information about me, please check out <a
-        class="text-link underline"
-        href="https://badmanners.xyz/"
-        data-age-restricted
-        rel="me">my main website</a
-      >.
-    </p>
+    <div class="my-4 flex">
+      <p class="grow">
+        Hey there, welcome to my corner of the Internet! This is where I'll share all of the safe vore and endosoma
+        content that I'll make. You can check the latest uploads below, or use the navigation bar to dig through all of
+        my content.
+      </p>
+      <a class="u-url text-link ml-2 mr-10 p-2" href="/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
+        <IconSquareRSS width="2rem" height="2rem" />
+        <span class="sr-only">RSS feed</span>
+      </a>
+    </div>
   </div>
   <section class="my-2" aria-labelledby="latest-uploads">
     <h2 id="latest-uploads" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">Latest uploads</h2>
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index 58144c4..fa2823f 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -5,6 +5,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "@layouts/GalleryLayout.astro";
 import { t } from "@i18n";
 import UserComponent from "@components/UserComponent.astro";
+import { IconSquareRSS } from "@components/icons";
 
 type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } };
 
@@ -30,18 +31,22 @@ const totalPages = Math.ceil(page.total / page.size);
 
 <GalleryLayout pageTitle="Stories" class="h-feed">
   <meta slot="head" property="og:description" content={`Bad Manners || ${page.total} stories.`} />
-  <h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Stories</h1>
+  <h1 class="p-name m-2 grow text-3xl font-semibold text-stone-800 dark:text-stone-100">Stories</h1>
   <hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
-  <div class="p-summary">
-    <p class="my-4">The bulk of my content!</p>
-    <p class="text-center font-light text-stone-950 dark:text-white">
-      {
-        page.start === page.end
-          ? `Displaying story #${page.start + 1}`
-          : `Displaying stories #${page.start + 1}–${page.end + 1}`
-      } / {page.total}
-    </p>
+  <div class="my-4 flex">
+    <p class="p-summary grow">The bulk of my content!</p>
+    <a class="u-url text-link ml-2 mr-10 p-2" href="/stories/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
+      <IconSquareRSS width="2rem" height="2rem" />
+      <span class="sr-only">RSS feed</span>
+    </a>
   </div>
+  <p class="text-center font-light text-stone-950 dark:text-white">
+    {
+      page.start === page.end
+        ? `Displaying story #${page.start + 1}`
+        : `Displaying stories #${page.start + 1}–${page.end + 1}`
+    } / {page.total}
+  </p>
   <div class="mx-auto mb-6 mt-2 flex w-fit rounded-lg border border-stone-400 dark:border-stone-500">
     {
       page.url.prev && (
diff --git a/src/pages/stories/feed.xml.ts b/src/pages/stories/feed.xml.ts
new file mode 100644
index 0000000..5388778
--- /dev/null
+++ b/src/pages/stories/feed.xml.ts
@@ -0,0 +1,26 @@
+import rss from "@astrojs/rss";
+import type { APIRoute } from "astro";
+import { getCollection } from "astro:content";
+import { storyFeedItem, type EntryWithPubDate } from "@utils/feed";
+
+const MAX_ITEMS = 8;
+
+export const GET: APIRoute = async ({ site }) => {
+  const stories = (
+    (await getCollection(
+      "stories",
+      (story) => !story.data.isDraft && story.data.pubDate,
+    )) as EntryWithPubDate<"stories">[]
+  )
+    .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+    .slice(0, MAX_ITEMS);
+
+  return rss({
+    title: "Stories | Bad Manners",
+    description: "Stories by Bad Manners.",
+    site: new URL("/stories/1", site!),
+    items: await Promise.all(
+      stories.map(async ({ data, slug, render }) => storyFeedItem(site, data, slug, (await render()).Content)),
+    ),
+  });
+};
diff --git a/src/utils/feed.ts b/src/utils/feed.ts
new file mode 100644
index 0000000..c77b5e6
--- /dev/null
+++ b/src/utils/feed.ts
@@ -0,0 +1,139 @@
+import type { RSSFeedItem } from "@astrojs/rss";
+import { getEntries, getEntry, type CollectionEntry, type CollectionKey, type Render } from "astro:content";
+import { experimental_AstroContainer } from "astro/container";
+import sanitizeHtml from "sanitize-html";
+import { getUsernameForLang } from "./get_username_for_lang";
+import { markdownToPlaintext } from "./markdown_to_plaintext";
+import { qualifyLocalURLsInMarkdown } from "./qualify_local_urls_in_markdown";
+import { markdown } from "@astropub/md";
+import { t, type Lang } from "@i18n";
+import type { AstroComponentFactory } from "astro/runtime/server/index.js";
+import mdxRenderer from "astro/jsx/server.js";
+
+export type FeedItem = RSSFeedItem &
+  Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>;
+
+export type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } };
+
+const container = await experimental_AstroContainer.create();
+container.addServerRenderer({ renderer: mdxRenderer } as any);
+
+function toNoonUTCDate(date: Date) {
+  const adjustedDate = new Date(date);
+  adjustedDate.setUTCHours(12);
+  return adjustedDate;
+}
+
+const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
+  const userName = getUsernameForLang(user, lang);
+  if (user.data.preferredLink) {
+    return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`;
+  }
+  return userName;
+};
+
+export async function storyFeedItem(
+  site: URL | undefined,
+  data: EntryWithPubDate<"stories">["data"],
+  slug: CollectionEntry<"stories">["slug"],
+  content: AstroComponentFactory,
+): Promise<FeedItem> {
+  return {
+    title: `New story! "${data.title}"`,
+    pubDate: toNoonUTCDate(data.pubDate),
+    link: `/stories/${slug}`,
+    description:
+      `${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
+        /[\n ]+/g,
+        " ",
+      ),
+    categories: ["story"],
+    commentsUrl: data.posts.mastodon?.link,
+    content: sanitizeHtml(
+      `<h1>${data.title}</h1>` +
+        `<p>${t(
+          data.lang,
+          "story/authors",
+          (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
+        )}</p>` +
+        (data.requesters
+          ? `<p>${t(
+              data.lang,
+              "story/requested_by",
+              (await getEntries(data.requesters)).map((requester) => getLinkForUser(requester, data.lang)),
+            )}</p>`
+          : "") +
+        (data.commissioners
+          ? `<p>${t(
+              data.lang,
+              "story/commissioned_by",
+              (await getEntries(data.commissioners)).map((commissioner) => getLinkForUser(commissioner, data.lang)),
+            )}</p>`
+          : "") +
+        `<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` +
+        `<hr>${await container.renderToString(content)}` +
+        `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
+    ),
+  };
+}
+
+export async function gameFeedItem(
+  site: URL | undefined,
+  data: EntryWithPubDate<"games">["data"],
+  slug: CollectionEntry<"games">["slug"],
+  content: AstroComponentFactory,
+): Promise<FeedItem> {
+  return {
+    title: `New game! "${data.title}"`,
+    pubDate: toNoonUTCDate(data.pubDate),
+    link: `/games/${slug}`,
+    description:
+      `${t(data.lang, "game/warnings", data.platforms, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
+        /[\n ]+/g,
+        " ",
+      ),
+    categories: ["game"],
+    commentsUrl: data.posts.mastodon?.link,
+    content: sanitizeHtml(
+      `<h1>${data.title}</h1>` +
+        `<p>${t(
+          data.lang,
+          "story/authors",
+          (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
+        )}</p>` +
+        `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
+        `<hr><p><em>${data.contentWarning}</em></p>` +
+        `<hr>${await container.renderToString(content)}` +
+        `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
+    ),
+  };
+}
+
+export async function blogFeedItem(
+  site: URL | undefined,
+  data: EntryWithPubDate<"blog">["data"],
+  slug: CollectionEntry<"blog">["slug"],
+  content: AstroComponentFactory,
+): Promise<FeedItem> {
+  return {
+    title: `New blog post! "${data.title}"`,
+    pubDate: toNoonUTCDate(data.pubDate),
+    link: `/blog/${slug}`,
+    description: markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site)).replaceAll(
+      /[\n ]+/g,
+      " ",
+    ),
+    categories: ["blog post"],
+    commentsUrl: data.posts.mastodon?.link,
+    content: sanitizeHtml(
+      `<h1>${data.title}</h1>` +
+        `<p>${t(
+          data.lang,
+          "story/authors",
+          (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
+        )}</p>` +
+        `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}` +
+        `<hr>${await container.renderToString(content)}`,
+    ),
+  };
+}