From d4a9dc9dbc3c07bcb3f9b4bb55dc2a8018480629 Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Thu, 21 Mar 2024 22:24:58 -0300
Subject: [PATCH] Add description exports and collapse characters from same
 user

---
 README.md                                     |   2 +-
 package-lock.json                             |  27 ++
 package.json                                  |   3 +
 src/components/Authors.astro                  |  20 +-
 src/components/CopyrightedCharacters.astro    |  54 +++-
 src/components/UserComponent.astro            |  34 ++-
 src/content/config.ts                         |  64 ++--
 src/content/games/crossing-over.md            |   2 +-
 src/content/stories/accommodation.md          |   2 +-
 src/content/stories/addictive-additions.md    |   6 +-
 src/content/stories/annivoresary.md           |   2 +-
 src/content/stories/better-in-bully-batter.md |   6 +-
 src/content/stories/big-haul.md               |   2 +-
 src/content/stories/birdroom.md               |   6 +-
 src/content/stories/bladder-filler.md         |   4 +-
 .../stories/bottom-of-the-food-chain.md       |   4 +-
 .../stories/butting-into-their-plans.md       |   2 +-
 src/content/stories/delicacy-s-dare.md        |   2 +-
 src/content/stories/eggs-for-months.md        |   2 +-
 src/content/stories/engaging-contacts.md      |   2 +-
 src/content/stories/flavorful-favor.md        |   4 +-
 src/content/stories/for-the-night.md          |   2 +-
 src/content/stories/gentle-and-cruel.md       |   2 +-
 src/content/stories/hate-to-sea-it.md         |   2 +-
 src/content/stories/hungry-for-love.md        |   2 +-
 src/content/stories/hyper-hunger.md           |   2 +-
 .../stories/insistence-and-assistance.md      |   2 +-
 src/content/stories/lactation-action.md       |   2 +-
 src/content/stories/latest-catch.md           |   2 +-
 src/content/stories/never-too-late.md         |   2 +-
 src/content/stories/noble-fire.md             |   2 +-
 src/content/stories/overzealous-zenko.md      |   6 +-
 src/content/stories/part-of-the-show.md       |   4 +-
 src/content/stories/pet-sit-saturday.md       |   2 +-
 .../stories/reaching-for-the-full-moon.md     |   2 +-
 src/content/stories/ruffling-some-feathers.md |   4 +-
 src/content/stories/spontaneous-sleepover.md  |   2 +-
 src/content/stories/taken-in.md               |   5 +-
 .../stories/tasting-high-consequences.md      |   2 +-
 src/content/stories/team-building.md          |   6 +-
 src/content/stories/team-effort.md            |   6 +-
 src/content/stories/the-last-livestream.md    |   2 +-
 .../bonus-1-quince-s-fantasy.md               |   2 +-
 .../the-lost-of-the-marshes/chapter-1.md      |   2 +-
 .../the-lost-of-the-marshes/chapter-10.md     |   2 +-
 .../the-lost-of-the-marshes/chapter-11.md     |   2 +-
 .../the-lost-of-the-marshes/chapter-2.md      |   2 +-
 .../the-lost-of-the-marshes/chapter-3.md      |   2 +-
 .../the-lost-of-the-marshes/chapter-4.md      |   2 +-
 .../the-lost-of-the-marshes/chapter-5.md      |   2 +-
 .../the-lost-of-the-marshes/chapter-6.md      |   2 +-
 .../the-lost-of-the-marshes/chapter-7.md      |   2 +-
 .../the-lost-of-the-marshes/chapter-8.md      |   2 +-
 .../the-lost-of-the-marshes/chapter-9.md      |   2 +-
 src/content/stories/tomo-moku.md              |   2 +-
 src/content/stories/trouble-sleeping.md       |   2 +-
 src/content/stories/warped-friendship.md      |   8 +-
 src/content/stories/within-limits.md          |   6 +-
 src/content/stories/you-re-home.md            |   2 +-
 src/content/users/asofyeun.json               |  11 +
 src/content/users/avour-inden.json            |   7 +
 src/content/users/bad-manners.json            |  20 ++
 src/content/users/dee-lumeni.json             |   7 +
 src/content/users/holi.json                   |   7 +
 src/content/users/scion.json                  |   8 +
 src/content/users/yolkmonkey.json             |   8 +
 src/layouts/GameLayout.astro                  |  19 +-
 src/layouts/StoryLayout.astro                 |  31 +-
 src/pages/_stories.astro                      |  36 ---
 src/pages/feed.xml.ts                         |  10 +-
 src/pages/games.astro                         |  10 +-
 src/pages/games/[...slug].astro               |  17 +-
 src/pages/stories/[...slug].astro             |   5 +-
 src/pages/stories/[page].astro                |  22 +-
 .../stories/export/[website]/[...slug].ts     | 274 ++++++++++++++++++
 .../stories/the-lost-of-the-marshes.astro     |  11 +-
 src/pages/tags.astro                          |  69 +++--
 src/pages/tags/[slug].astro                   |  13 +-
 78 files changed, 693 insertions(+), 247 deletions(-)
 create mode 100644 src/content/users/asofyeun.json
 create mode 100644 src/content/users/avour-inden.json
 create mode 100644 src/content/users/bad-manners.json
 create mode 100644 src/content/users/dee-lumeni.json
 create mode 100644 src/content/users/holi.json
 create mode 100644 src/content/users/scion.json
 create mode 100644 src/content/users/yolkmonkey.json
 delete mode 100644 src/pages/_stories.astro
 create mode 100644 src/pages/stories/export/[website]/[...slug].ts

diff --git a/README.md b/README.md
index eb2c60c..70134df 100644
--- a/README.md
+++ b/README.md
@@ -7,5 +7,5 @@
 ```bash
 npm install
 npm run build
-scp -r dist/ my-ssh-server:./gallery.badmanners.xyz
+scp -r ./dist/* my-ssh-server:./gallery.badmanners.xyz/
 ```
diff --git a/package-lock.json b/package-lock.json
index a3b94d3..770a572 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,9 +13,12 @@
         "@astrojs/tailwind": "^5.1.0",
         "@astropub/md": "^0.4.0",
         "@tailwindcss/typography": "^0.5.10",
+        "@types/he": "^1.2.3",
         "astro": "^4.5.4",
         "date-fns": "^3.5.0",
         "github-slugger": "^2.0.0",
+        "he": "^1.2.0",
+        "marked": "^12.0.1",
         "tailwindcss": "^3.4.1",
         "typescript": "^5.4.2"
       },
@@ -1278,6 +1281,11 @@
         "@types/unist": "*"
       }
     },
+    "node_modules/@types/he": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@types/he/-/he-1.2.3.tgz",
+      "integrity": "sha512-q67/qwlxblDzEDvzHhVkwc1gzVWxaNxeyHUBF4xElrvjL11O+Ytze+1fGpBHlr/H9myiBUaUXNnNPmBHxxfAcA=="
+    },
     "node_modules/@types/mdast": {
       "version": "4.0.3",
       "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz",
@@ -3040,6 +3048,14 @@
         "url": "https://opencollective.com/unified"
       }
     },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "bin": {
+        "he": "bin/he"
+      }
+    },
     "node_modules/html-escaper": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -3519,6 +3535,17 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
+    "node_modules/marked": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.1.tgz",
+      "integrity": "sha512-Y1/V2yafOcOdWQCX0XpAKXzDakPOpn6U0YLxTJs3cww6VxOzZV1BTOOYWLvH3gX38cq+iLwljHHTnMtlDfg01Q==",
+      "bin": {
+        "marked": "bin/marked.js"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
     "node_modules/mdast-util-definitions": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz",
diff --git a/package.json b/package.json
index fca265e..08e972b 100644
--- a/package.json
+++ b/package.json
@@ -16,9 +16,12 @@
     "@astrojs/tailwind": "^5.1.0",
     "@astropub/md": "^0.4.0",
     "@tailwindcss/typography": "^0.5.10",
+    "@types/he": "^1.2.3",
     "astro": "^4.5.4",
     "date-fns": "^3.5.0",
     "github-slugger": "^2.0.0",
+    "he": "^1.2.0",
+    "marked": "^12.0.1",
     "tailwindcss": "^3.4.1",
     "typescript": "^5.4.2"
   },
diff --git a/src/components/Authors.astro b/src/components/Authors.astro
index 9b9d269..f19ebde 100644
--- a/src/components/Authors.astro
+++ b/src/components/Authors.astro
@@ -1,9 +1,10 @@
 ---
-import { type Lang, type User } from "../content/config";
+import { type CollectionEntry } from "astro:content";
+import { type Lang } from "../content/config";
 import UserComponent from "./UserComponent.astro";
 
 type Props = {
-  authors: User | User[];
+  authors: CollectionEntry<"users"> | CollectionEntry<"users">[];
   lang: Lang;
 };
 
@@ -20,18 +21,19 @@ const authorsArray = [authors].flat();
             by{" "}
             {authorsArray.slice(0, authorsArray.length - 1).map((author) => (
               <Fragment>
-                <UserComponent user={author} />,
+                <UserComponent lang="eng" user={author} />,
               </Fragment>
             ))}
-            and <UserComponent user={authorsArray[authorsArray.length - 1]} />
+            and <UserComponent lang="eng" user={authorsArray[authorsArray.length - 1]} />
           </span>
         ) : authorsArray.length > 1 ? (
           <span>
-            by <UserComponent user={authorsArray[0]} /> and <UserComponent user={authorsArray[1]} />
+            by <UserComponent lang="eng" user={authorsArray[0]} /> and{" "}
+            <UserComponent lang="eng" user={authorsArray[1]} />
           </span>
         ) : (
           <span>
-            by <UserComponent user={authorsArray[0]} />
+            by <UserComponent lang="eng" user={authorsArray[0]} />
           </span>
         ))}
       {lang === "tok" &&
@@ -40,15 +42,15 @@ const authorsArray = [authors].flat();
             lipu ni li tan ni:{" "}
             {authorsArray.slice(0, authorsArray.length - 1).map((author) => (
               <Fragment>
-                <UserComponent user={author} />
+                <UserComponent lang="tok" user={author} />
                 {" en "}
               </Fragment>
             ))}
-            <UserComponent user={authorsArray[authorsArray.length - 1]} />
+            <UserComponent lang="tok" user={authorsArray[authorsArray.length - 1]} />
           </span>
         ) : (
           <span>
-            lipu ni li tan <UserComponent user={authorsArray[0]} />
+            lipu ni li tan <UserComponent lang="tok" user={authorsArray[0]} />
           </span>
         ))}
     </p>
diff --git a/src/components/CopyrightedCharacters.astro b/src/components/CopyrightedCharacters.astro
index 27a5f01..a1fd2db 100644
--- a/src/components/CopyrightedCharacters.astro
+++ b/src/components/CopyrightedCharacters.astro
@@ -1,27 +1,67 @@
 ---
-import { type Lang, type User } from "../content/config";
+import { type CollectionEntry } from "astro:content";
+import { type Lang } from "../content/config";
 import UserComponent from "./UserComponent.astro";
 
 type Props = {
-  copyrightedCharacters?: Record<string, User>;
+  copyrightedCharacters?: Record<string, CollectionEntry<"users">>;
   lang: Lang;
 };
 
 const { copyrightedCharacters, lang } = Astro.props;
+if (copyrightedCharacters && "" in copyrightedCharacters && Object.keys(copyrightedCharacters).length > 1) {
+  throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
+}
+const charactersPerUser =
+  copyrightedCharacters &&
+  Object.keys(copyrightedCharacters).reduce(
+    (acc, character) => {
+      const key = copyrightedCharacters[character].id;
+      if (!(key in acc)) {
+        acc[key] = [];
+      }
+      acc[key].push(character);
+      return acc;
+    },
+    {} as Record<
+      CollectionEntry<"users">["id"],
+      (typeof copyrightedCharacters extends Record<infer K, any> ? K : never)[]
+    >,
+  );
 ---
 
 {
-  copyrightedCharacters ? (
+  charactersPerUser ? (
     <section id="copyrighted-characters">
-      {lang === "eng" && (
+      {lang === "eng" ? (
         <ul>
-          {Object.entries(copyrightedCharacters).map(([character, user]) => (
+          {Object.values(charactersPerUser).map((characterList) => (
             <li>
-              {character} is &copy; <UserComponent user={user} />
+              {characterList[0] === "" ? (
+                <span>
+                  All characters are &copy; <UserComponent lang={lang} user={copyrightedCharacters[""]} />
+                </span>
+              ) : characterList.length > 2 ? (
+                <span>
+                  {characterList.slice(0, characterList.length - 1).join(", ")}, and{" "}
+                  {characterList[characterList.length - 1]} are &copy;{" "}
+                  <UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
+                </span>
+              ) : characterList.length > 1 ? (
+                <span>
+                  {characterList[0]} and {characterList[1]} are &copy;{" "}
+                  <UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
+                </span>
+              ) : (
+                <span>
+                  {characterList[0]} is &copy;{" "}
+                  <UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
+                </span>
+              )}
             </li>
           ))}
         </ul>
-      )}
+      ) : null}
     </section>
   ) : null
 }
diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro
index 3f0334b..10aa1b7 100644
--- a/src/components/UserComponent.astro
+++ b/src/components/UserComponent.astro
@@ -1,21 +1,35 @@
 ---
-import { type User } from "../content/config";
+import { type CollectionEntry } from "astro:content";
+import { type Lang } from "../content/config";
 
 type Props = {
-  user: User;
+  lang: Lang;
+  user: CollectionEntry<"users">;
 };
 
-const { user } = Astro.props;
+const { user, lang } = Astro.props;
+const username = user.data.nameLang[lang] || user.data.name;
+let link: string | null = null;
+if (user.data.preferredLink) {
+  if (user.data.preferredLink in user.data.links) {
+    const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
+    if (typeof preferredLink === "string") {
+      link = preferredLink;
+    } else {
+      link = preferredLink[0];
+    }
+  } else {
+    throw new Error(`No preferredLink "${user.data.preferredLink}" for user ${user.id}`);
+  }
+}
 ---
 
 {
-  typeof user === "string" ? (
-    <span>{user}</span>
+  user.data.preferredLink == null ? (
+    <span>{username}</span>
   ) : (
-    Object.entries(user).map(([k, v]) => (
-      <a href={v} class="text-link underline" target="_blank">
-        <span>{k}</span>
-      </a>
-    ))[0]
+    <a href={link} class="text-link underline" target="_blank">
+      {username}
+    </a>
   )
 }
diff --git a/src/content/config.ts b/src/content/config.ts
index a4ca84b..b95294c 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -1,39 +1,52 @@
 import { defineCollection, reference, z } from "astro:content";
 
-const user = z.union([z.string(), z.record(z.string().url())]);
+export const WEBSITE_LIST = [
+  "website",
+  "eka",
+  "furaffinity",
+  "weasyl",
+  "inkbunny",
+  "sofurry",
+  "twitter",
+  "mastodon",
+  "bluesky",
+] as const;
 
 const lang = z.enum(["eng", "tok"]).default("eng");
+const website = z.enum(WEBSITE_LIST);
 
-export type User = z.output<typeof user>;
 export type Lang = z.output<typeof lang>;
+export type Website = z.infer<typeof website>;
 
 const storiesCollection = defineCollection({
   type: "content",
   schema: ({ image }) =>
     z.object({
+      // Required
       title: z.string(),
-      shortTitle: z.string().optional(),
       pubDate: z.date(),
-      isDraft: z.boolean().default(false),
-      authors: z.union([user, z.array(user)]).default("Bad Manners"),
       wordCount: z.number().int(),
       contentWarning: z.string(),
       description: z.string(),
+      tags: z.array(z.string()),
+      // Optional
+      isDraft: z.boolean().default(false),
+      shortTitle: z.string().optional(),
+      authors: z.union([reference("users"), z.array(reference("users"))]).default("bad-manners"),
       descriptionPlaintext: z.string().optional(),
       summary: z.string().optional(),
       thumbnail: image().optional(),
       thumbnailWidth: z.number().int().optional(),
       thumbnailHeight: z.number().int().optional(),
-      tags: z.array(z.string()),
       series: z.record(z.string(), z.string()).optional(),
-      commissioner: user.optional(),
-      requester: user.optional(),
-      copyrightedCharacters: z.record(z.string(), user).optional(),
+      commissioner: reference("users").optional(),
+      requester: reference("users").optional(),
+      copyrightedCharacters: z.record(z.string(), reference("users")).default({}),
       lang,
       prev: reference("stories").nullable().optional(),
       next: reference("stories").nullable().optional(),
-      relatedStories: z.array(reference("stories")).optional(),
-      relatedGames: z.array(reference("games")).optional(),
+      relatedStories: z.array(reference("stories")).default([]),
+      relatedGames: z.array(reference("games")).default([]),
     }),
 });
 
@@ -41,26 +54,43 @@ const gamesCollection = defineCollection({
   type: "content",
   schema: ({ image }) =>
     z.object({
+      // Required
       title: z.string(),
       pubDate: z.date(),
-      isDraft: z.boolean().default(false),
-      authors: z.union([user, z.array(user)]).default("Bad Manners"),
       contentWarning: z.string(),
       description: z.string(),
+      tags: z.array(z.string()),
+      // Optional
+      isDraft: z.boolean().default(false),
+      authors: z.union([reference("users"), z.array(reference("users"))]).default("bad-manners"),
       descriptionPlaintext: z.string().optional(),
       thumbnail: image().optional(),
       thumbnailWidth: z.number().int().optional(),
       thumbnailHeight: z.number().int().optional(),
-      tags: z.array(z.string()),
       series: z.record(z.string(), z.string()).optional(),
-      copyrightedCharacters: z.record(z.string(), user).optional(),
+      copyrightedCharacters: z.record(z.string(), reference("users")).default({}),
       lang,
-      relatedStories: z.array(reference("stories")).optional(),
-      relatedGames: z.array(reference("games")).optional(),
+      relatedStories: z.array(reference("stories")).default([]),
+      relatedGames: z.array(reference("games")).default([]),
+    }),
+});
+
+const usersCollection = defineCollection({
+  type: "data",
+  schema: ({ image }) =>
+    z.object({
+      // Required
+      name: z.string(),
+      links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
+      preferredLink: website.nullable(),
+      // Optional
+      nameLang: z.record(lang, z.string()).default({}),
+      avatar: image().optional(),
     }),
 });
 
 export const collections = {
   stories: storiesCollection,
   games: gamesCollection,
+  users: usersCollection,
 };
diff --git a/src/content/games/crossing-over.md b/src/content/games/crossing-over.md
index c6efe0b..eda683c 100644
--- a/src/content/games/crossing-over.md
+++ b/src/content/games/crossing-over.md
@@ -1,7 +1,7 @@
 ---
 title: Crossing Over
 pubDate: 2024-02-28
-authors: Bad Manners
+authors: bad-manners
 contentWarning: >
   This visual novel is a game about death, fishing, and vore. It contains purely fictional content deemed inappropriate for minors. It also deals with heavy subject matters like depression, abuse, and suicide, which may be unsuitable for some audiences. If you continue, you acknowledge that you're an adult, and accept responsibility for your actions.
 thumbnail: /src/assets/thumbnails/game_crossing_over_cover.png
diff --git a/src/content/stories/accommodation.md b/src/content/stories/accommodation.md
index 4cbe99b..8bc33c3 100644
--- a/src/content/stories/accommodation.md
+++ b/src/content/stories/accommodation.md
@@ -1,7 +1,7 @@
 ---
 title: Accommodation
 pubDate: 2023-01-03
-authors: Bad Manners
+authors: bad-manners
 wordCount: 4800
 contentWarning: >
   Contains: Non-fatal size difference anal vore, with unwilling to willing female okapi predator, unwilling to willing male gray wolf prey, and long-term endosoma. Also includes straight sexual situations.
diff --git a/src/content/stories/addictive-additions.md b/src/content/stories/addictive-additions.md
index 53025b2..d34645c 100644
--- a/src/content/stories/addictive-additions.md
+++ b/src/content/stories/addictive-additions.md
@@ -1,7 +1,7 @@
 ---
 title: Addictive Additions
 pubDate: 2022-12-27
-authors: Bad Manners
+authors: bad-manners
 wordCount: 11200
 contentWarning: >
   Contains: Non-fatal oral vore and anal vore, with willing pred and multiple consensual similar sized prey (both willing, and semi-willing to unwilling in partial vore), and implied perma endo with trait theft. Also includes consensual sexual situations (M/M, M/F), hyper, male pregnancy worship, marriage play, clothing play, and semi-public lewdness.
@@ -31,9 +31,9 @@ tags:
     "netorare",
     "commission",
   ]
-commissioner: { "Scion": "https://www.furaffinity.net/user/scionic" }
+commissioner: scion
 copyrightedCharacters:
-  "": { "Scion": "https://www.furaffinity.net/user/scionic" }
+  "": scion
 ---
 
 "A'ight, this place should be quiet enough." A resounding, confident manly voice boomed in the dark backroom. Music from a party kept playing far away, only the beats being discernible at this distance.
diff --git a/src/content/stories/annivoresary.md b/src/content/stories/annivoresary.md
index 3d1b6b7..cf586f4 100644
--- a/src/content/stories/annivoresary.md
+++ b/src/content/stories/annivoresary.md
@@ -1,7 +1,7 @@
 ---
 title: Annivoresary
 pubDate: 2022-08-08
-authors: Bad Manners
+authors: bad-manners
 wordCount: 3000
 contentWarning: >
   Contains: willing, non-fatal oral vore, with smaller male anthro fox pred, and larger male anthro wolf prey. Also includes bondage and sexual situations.
diff --git a/src/content/stories/better-in-bully-batter.md b/src/content/stories/better-in-bully-batter.md
index 33d85a8..c0673b9 100644
--- a/src/content/stories/better-in-bully-batter.md
+++ b/src/content/stories/better-in-bully-batter.md
@@ -1,7 +1,7 @@
 ---
 title: Better in Bully Batter
 pubDate: 2023-02-20
-authors: Bad Manners
+authors: bad-manners
 wordCount: 19100
 contentWarning: >
   Contains: Non-fatal similar size cock vore, with willing pred, multiple unwilling/semi-willing prey, and implied perma endo with trait theft. Also includes consensual sexual situations (M/M, M/F), hyper, cock sizeplay, netorare/cuckoldry and marriage play, cum inflation and weight gain, auto-fellatio, public sexual situations, and public vore.
@@ -29,9 +29,9 @@ tags:
     "netorare",
     "commission",
   ]
-commissioner: { "Scion": "https://www.furaffinity.net/user/scionic" }
+commissioner: scion
 copyrightedCharacters:
-  "": { "Scion": "https://www.furaffinity.net/user/scionic" }
+  "": scion
 ---
 
 ## The first day
diff --git a/src/content/stories/big-haul.md b/src/content/stories/big-haul.md
index fd10afb..cabf388 100644
--- a/src/content/stories/big-haul.md
+++ b/src/content/stories/big-haul.md
@@ -1,7 +1,7 @@
 ---
 title: Big Haul
 pubDate: 2023-11-20
-authors: Bad Manners
+authors: bad-manners
 wordCount: 9100
 contentWarning: >
   Contains: Non-fatal size difference unbirth, with semi-willing trans male anthro lemur pred, and unwilling cis male anthro sergal prey. Also includes gay sex with trans and cis characters, fatfur, daddy play, implied long-term endosoma, booze, and rudeness.
diff --git a/src/content/stories/birdroom.md b/src/content/stories/birdroom.md
index 892942f..d9406df 100644
--- a/src/content/stories/birdroom.md
+++ b/src/content/stories/birdroom.md
@@ -1,7 +1,7 @@
 ---
 title: Birdroom
 pubDate: 2023-05-17
-authors: Bad Manners
+authors: bad-manners
 wordCount: 3000
 contentWarning: >
   Contains: non-fatal similar size anal vore, willing male feral gryphon pred, willing male anthro mimic hybrid prey, gay sex.
@@ -31,8 +31,8 @@ tags:
     "gay sex",
   ]
 copyrightedCharacters:
-  Beetle: Bad Manners
-  "Sam Brendan": Bad Manners
+  Beetle: bad-manners
+  "Sam Brendan": bad-manners
 ---
 
 "Staaaaan!" The gryphon's voice squawked across rooms, as Beetle happily strutted with his lion paws. "Where areeee yooooou~?" He sang his question, letting his wagging tail follow his orange body as he checked room after room. "Oh, there you are, Stan!"
diff --git a/src/content/stories/bladder-filler.md b/src/content/stories/bladder-filler.md
index 8eb0d35..d3a977f 100644
--- a/src/content/stories/bladder-filler.md
+++ b/src/content/stories/bladder-filler.md
@@ -1,7 +1,7 @@
 ---
 title: Bladder Filler
 pubDate: 2023-11-08
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1900
 contentWarning: >
   Contains: non-fatal size difference cock vore to clean bladder, with willing male feral gryphon pred, and willing 2nd person PoV feral dragon prey. Also includes implied long-term endosoma.
@@ -35,7 +35,7 @@ tags:
     "flash fiction",
   ]
 copyrightedCharacters:
-  Beetle: Bad Manners
+  Beetle: bad-manners
 ---
 
 "...You wanna go WHERE?" The gryphon looming over you squawked. It was hard to tell from his high-pitched tone if he was being incredulous, or making sure that he'd heard you right.
diff --git a/src/content/stories/bottom-of-the-food-chain.md b/src/content/stories/bottom-of-the-food-chain.md
index a03d35a..5c1c77e 100644
--- a/src/content/stories/bottom-of-the-food-chain.md
+++ b/src/content/stories/bottom-of-the-food-chain.md
@@ -1,7 +1,7 @@
 ---
 title: Bottom of the Food Chain
 pubDate: 2023-09-27
-authors: Bad Manners
+authors: bad-manners
 wordCount: 4500
 contentWarning: >
   Contains: Non-fatal size difference oral vore, with semi-willing male feral snake pred and semi-willing feral vole prey.
@@ -28,7 +28,7 @@ tags:
   ]
 prev: ruffling-some-feathers
 copyrightedCharacters:
-  Muno: Bad Manners
+  Muno: bad-manners
 ---
 
 Dark gray plates slithered across the track. It was hard for an outside observer to tell, but the snake that the plates belonged to was in a hurry. Muno's destination was still a couple of days away, but his recent altercation with a rather hungry predator pressed him even more for time. He lamented how his mint-hued scales still smelled like owl breath even after a quick bath down the river, but he could only blame himself – and he had a job to worry about instead.
diff --git a/src/content/stories/butting-into-their-plans.md b/src/content/stories/butting-into-their-plans.md
index b8f97c0..be9ca10 100644
--- a/src/content/stories/butting-into-their-plans.md
+++ b/src/content/stories/butting-into-their-plans.md
@@ -1,7 +1,7 @@
 ---
 title: Butting into Their Plans
 pubDate: 2023-02-18
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1400
 contentWarning: >
   Contains: first person point-of-view, non-fatal size difference anal vore, willing male feral drake pred, willing PoV ambiguous anthro prey, rimming.
diff --git a/src/content/stories/delicacy-s-dare.md b/src/content/stories/delicacy-s-dare.md
index 48bbb02..2822bf8 100644
--- a/src/content/stories/delicacy-s-dare.md
+++ b/src/content/stories/delicacy-s-dare.md
@@ -1,7 +1,7 @@
 ---
 title: Delicacy's Dare
 pubDate: 2023-01-10
-authors: Bad Manners
+authors: bad-manners
 wordCount: 8000
 contentWarning: >
   Contains: Non-fatal micro oral vore, with willing male deer predator, willing to unwilling male dragon prey, and messy stomach with food digestion.
diff --git a/src/content/stories/eggs-for-months.md b/src/content/stories/eggs-for-months.md
index 8c40543..bb73fa2 100644
--- a/src/content/stories/eggs-for-months.md
+++ b/src/content/stories/eggs-for-months.md
@@ -1,7 +1,7 @@
 ---
 title: Eggs for Months
 pubDate: 2022-07-20
-authors: Bad Manners
+authors: bad-manners
 wordCount: 7700
 contentWarning: >
   Contains: size difference, non-fatal sheath vore, with male feral gryphon pred and female anthro crow prey. Also includes dubious consent sex scenes, and lots of egg play and insertion (oral, cock, vaginal).
diff --git a/src/content/stories/engaging-contacts.md b/src/content/stories/engaging-contacts.md
index b95284d..876a0bb 100644
--- a/src/content/stories/engaging-contacts.md
+++ b/src/content/stories/engaging-contacts.md
@@ -1,7 +1,7 @@
 ---
 title: Engaging Contacts
 pubDate: 2023-08-08
-authors: Bad Manners
+authors: bad-manners
 wordCount: 6000
 contentWarning: >
   Contains: Non-fatal size difference oral vore and unbirth, with willing female wyvern pred and multiple unwilling/semi-willing/willing female anthro prey. Also includes lesbian sex, pet play, and implied full tour.
diff --git a/src/content/stories/flavorful-favor.md b/src/content/stories/flavorful-favor.md
index e33c4e4..e171cd6 100644
--- a/src/content/stories/flavorful-favor.md
+++ b/src/content/stories/flavorful-favor.md
@@ -1,7 +1,7 @@
 ---
 title: Flavorful Favor
 pubDate: 2023-04-14
-authors: Bad Manners
+authors: bad-manners
 wordCount: 9400
 contentWarning: >
   Contains: Non-fatal oral vore, cock vore, and unbirth, with willing male gryphon predator, willing/semi-willing smaller female kobold predator/prey, and unwilling/semi-willing micro male mouse prey. Also includes full tour, prey transfer (cock to womb), and straight/gay sexual situations.
@@ -37,7 +37,7 @@ tags:
     "gay sex",
   ]
 copyrightedCharacters:
-  Beetle: Bad Manners
+  Beetle: bad-manners
 ---
 
 Rondo scampered towards his friend's cave. The mouse's cheeks were still blushing from his determination to profess his love for Sonatina. Sure, he may have been a tiny rodent, as big as the already small kobold's fist... But still! Love had flourished within stranger pairings, before – it certainly did in all sorts of romance novels he'd devour. And he and Tina had known each other for years, spending some time together in this woods whenever they had the chance...but just as friends. The orange mouse hoped to rectify that.
diff --git a/src/content/stories/for-the-night.md b/src/content/stories/for-the-night.md
index 92cf38f..6d6a6a8 100644
--- a/src/content/stories/for-the-night.md
+++ b/src/content/stories/for-the-night.md
@@ -1,7 +1,7 @@
 ---
 title: For the Night
 pubDate: 2022-12-26
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1500
 contentWarning: >
   Contains: non-fatal same size anal vore, willing anthro male dog pred, willing anthro female pony prey, straight anal sex, threesome, sexuality play.
diff --git a/src/content/stories/gentle-and-cruel.md b/src/content/stories/gentle-and-cruel.md
index 19edea9..339f240 100644
--- a/src/content/stories/gentle-and-cruel.md
+++ b/src/content/stories/gentle-and-cruel.md
@@ -1,7 +1,7 @@
 ---
 title: Gentle and Cruel
 pubDate: 2023-10-31
-authors: Bad Manners
+authors: bad-manners
 wordCount: 5200
 contentWarning: >
   Contains: Non-fatal size difference oral vore, with gentle male anthro badger pred, cruel monster pred, and willing to unwilling male anthro lynx prey. Also includes regurgitation, aftercare, thriller/horror scenes, and implied transformation.
diff --git a/src/content/stories/hate-to-sea-it.md b/src/content/stories/hate-to-sea-it.md
index 49bbb75..d2e8409 100644
--- a/src/content/stories/hate-to-sea-it.md
+++ b/src/content/stories/hate-to-sea-it.md
@@ -1,7 +1,7 @@
 ---
 title: Hate to Sea It
 pubDate: 2023-02-18
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1200
 contentWarning: >
   Contains: non-fatal size difference unbirth, willing female feral orca pred, unwilling to semi-willing male feral dolphin prey, straight sex, hate sex.
diff --git a/src/content/stories/hungry-for-love.md b/src/content/stories/hungry-for-love.md
index 7209b16..506cd44 100644
--- a/src/content/stories/hungry-for-love.md
+++ b/src/content/stories/hungry-for-love.md
@@ -1,7 +1,7 @@
 ---
 title: Hungry for Love
 pubDate: 2023-02-14
-authors: Bad Manners
+authors: bad-manners
 wordCount: 5900
 contentWarning: >
   Contains: Non-fatal size difference oral vore, with willing male spider predator, and willing female badger prey. Also includes straight sexual situations.
diff --git a/src/content/stories/hyper-hunger.md b/src/content/stories/hyper-hunger.md
index a2b24c7..28029b4 100644
--- a/src/content/stories/hyper-hunger.md
+++ b/src/content/stories/hyper-hunger.md
@@ -1,7 +1,7 @@
 ---
 title: Hyper Hunger
 pubDate: 2022-12-05
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1300
 contentWarning: >
   Contains: non-fatal size difference oral vore, willing anthro ambiguous male pred, unwilling feral dog prey, food stuffing, messy stomach with smells, hyper cock, auto-fellatio.
diff --git a/src/content/stories/insistence-and-assistance.md b/src/content/stories/insistence-and-assistance.md
index 90f145e..6307703 100644
--- a/src/content/stories/insistence-and-assistance.md
+++ b/src/content/stories/insistence-and-assistance.md
@@ -1,7 +1,7 @@
 ---
 title: Insistence and Assistance
 pubDate: 2022-12-05
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1200
 contentWarning: >
   Contains: non-fatal same size oral vore, semi-willing anthro male cat pred, unwilling anthro male mouse prey, burping, regurgitation, force feeding, voyeurism.
diff --git a/src/content/stories/lactation-action.md b/src/content/stories/lactation-action.md
index c03a10f..1f20f00 100644
--- a/src/content/stories/lactation-action.md
+++ b/src/content/stories/lactation-action.md
@@ -1,7 +1,7 @@
 ---
 title: Lactation Action
 pubDate: 2022-12-26
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1400
 contentWarning: >
   Contains: non-fatal micro nipple vore and oral vore, willing anthro female ferret pred, willing anthro male brown bear prey/pred, willing/asleep anthro female seagull prey, breast play, shrinking and growing, lactation, breastfeeding, prey transfer, growing in stomach to same size, burping.
diff --git a/src/content/stories/latest-catch.md b/src/content/stories/latest-catch.md
index 152acdf..bce1055 100644
--- a/src/content/stories/latest-catch.md
+++ b/src/content/stories/latest-catch.md
@@ -1,7 +1,7 @@
 ---
 title: Latest Catch
 pubDate: 2022-12-26
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1500
 contentWarning: >
   Contains: non-fatal size difference cock vore, willing to semi-willing anthro non-binary rabbit pred, willing feral snake prey, masturbation, mouthplay, implied perma endo.
diff --git a/src/content/stories/never-too-late.md b/src/content/stories/never-too-late.md
index 7ed0b89..9cf4b23 100644
--- a/src/content/stories/never-too-late.md
+++ b/src/content/stories/never-too-late.md
@@ -1,7 +1,7 @@
 ---
 title: Never Too Late
 pubDate: 2022-12-05
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1100
 contentWarning: >
   Contains: non-fatal same size cock vore, asleep anthro male horse pred, willing anthro female aardwolf prey, masturbation, fellatio, alcohol.
diff --git a/src/content/stories/noble-fire.md b/src/content/stories/noble-fire.md
index cb1ace0..d5607b0 100644
--- a/src/content/stories/noble-fire.md
+++ b/src/content/stories/noble-fire.md
@@ -1,7 +1,7 @@
 ---
 title: Noble Fire
 pubDate: 2023-09-20
-authors: Bad Manners
+authors: bad-manners
 wordCount: 6900
 contentWarning: >
   Contains: Non-fatal same size oral vore, with willing male anthro lion pred and semi-willing male anthro dog prey. Also includes sexual nudity and heavy themes like violence, blood, trauma, abuse, and fear.
diff --git a/src/content/stories/overzealous-zenko.md b/src/content/stories/overzealous-zenko.md
index dcfb357..2ebb441 100644
--- a/src/content/stories/overzealous-zenko.md
+++ b/src/content/stories/overzealous-zenko.md
@@ -1,7 +1,7 @@
 ---
 title: Overzealous Zenko
 pubDate: 2023-08-08
-authors: Bad Manners
+authors: bad-manners
 wordCount: 4900
 contentWarning: >
   Contains: Non-fatal size difference chest maw vore, with willing male kitsune-human centaur pred, unwilling female human prey, and implied perma endo.
@@ -24,9 +24,9 @@ tags:
     "perma endo",
     "request",
   ]
-requester: { "Dee Lumeni": "https://aryion.com/g4/user/KeeperofLillies" }
+requester: dee-lumeni
 copyrightedCharacters:
-  Kuronosuke: { "Dee Lumeni": "https://aryion.com/g4/user/KeeperofLillies" }
+  Kuronosuke: dee-lumeni
 ---
 
 "Come on, open up." Kuronosuke banged on the apartment's door again a few times. "I know you're in there. You don't wanna get on my bad side."
diff --git a/src/content/stories/part-of-the-show.md b/src/content/stories/part-of-the-show.md
index a0f9845..fa14442 100644
--- a/src/content/stories/part-of-the-show.md
+++ b/src/content/stories/part-of-the-show.md
@@ -1,7 +1,7 @@
 ---
 title: Part of the Show
 pubDate: 2023-06-13
-authors: Bad Manners
+authors: bad-manners
 wordCount: 2000
 contentWarning: >
   Contains: non-fatal same size public oral vore, with willing male anthro mimic/maned wolf hybrid pred, semi-willing 2nd person PoV anthro prey. Also includes pole-dancing and mentions of alcohol.
@@ -29,7 +29,7 @@ tags:
     "flash fiction",
   ]
 copyrightedCharacters:
-  "Sam Brendan": Bad Manners
+  "Sam Brendan": bad-manners
 ---
 
 You make up your mind, and finally decide to visit this nightclub you've been told about. It was a 'one-in-a-lifetime experience', according to your friend. And everyone else who went there seems to agree with that sentiment. But honestly, what can be so interesting about people dancing around a pole? It isn't even a strip club performance... No matter. You already cleared up your agenda for tonight, and your interest is mildly piqued. You might as well visit the place. Alone, of course – you don't need any nosy witnesses; and if it turns out to be as boring as expected, you can just leave, no strings attached.
diff --git a/src/content/stories/pet-sit-saturday.md b/src/content/stories/pet-sit-saturday.md
index b30b373..4d2d3b9 100644
--- a/src/content/stories/pet-sit-saturday.md
+++ b/src/content/stories/pet-sit-saturday.md
@@ -1,7 +1,7 @@
 ---
 title: Pet-Sit Saturday
 pubDate: 2022-07-30
-authors: Bad Manners
+authors: bad-manners
 wordCount: 11000
 contentWarning: >
   Contains: same size, non-fatal anal vore, with female anthro elephant pred, female anthro anteater unwilling prey, and female feral zorgoia pred/willing prey. Also includes object vore (anal), prey transfer, and masturbation.
diff --git a/src/content/stories/reaching-for-the-full-moon.md b/src/content/stories/reaching-for-the-full-moon.md
index a76cbe7..132a9ac 100644
--- a/src/content/stories/reaching-for-the-full-moon.md
+++ b/src/content/stories/reaching-for-the-full-moon.md
@@ -1,7 +1,7 @@
 ---
 title: Reaching for the Full Moon
 pubDate: 2023-02-18
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1300
 contentWarning: >
   Contains: non-fatal oral vore, smaller unwilling anthro male rabbit pred, bigger willing werewolf prey, forced vore, role reversal.
diff --git a/src/content/stories/ruffling-some-feathers.md b/src/content/stories/ruffling-some-feathers.md
index 3baf8f4..33e39f1 100644
--- a/src/content/stories/ruffling-some-feathers.md
+++ b/src/content/stories/ruffling-some-feathers.md
@@ -1,7 +1,7 @@
 ---
 title: Ruffling Some Feathers
 pubDate: 2023-02-18
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1000
 contentWarning: >
   Contains: non-fatal size difference oral vore, willing feral male owl pred, semi-willing feral male snake prey.
@@ -29,7 +29,7 @@ tags:
   ]
 next: bottom-of-the-food-chain
 copyrightedCharacters:
-  Muno: Bad Manners
+  Muno: bad-manners
 ---
 
 Sovinne shifted in his sleep, feeling some minor discomfort. The brown saw-whet owl had been slumbering inside of the tree trunk hollow for the day, standing up with puffed up feathers. But he woke up when he felt something slick brushing against his skin, and yawned.
diff --git a/src/content/stories/spontaneous-sleepover.md b/src/content/stories/spontaneous-sleepover.md
index 1970fdf..1c33bbe 100644
--- a/src/content/stories/spontaneous-sleepover.md
+++ b/src/content/stories/spontaneous-sleepover.md
@@ -1,7 +1,7 @@
 ---
 title: Spontaneous Sleepover
 pubDate: 2022-12-26
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1300
 contentWarning: >
   Contains: non-fatal same size tail vore, willing anthro male squirrel pred, willing anthro female stoat prey, unwilling anthro female pangolin prey, public vore.
diff --git a/src/content/stories/taken-in.md b/src/content/stories/taken-in.md
index 495cb81..0910d6e 100644
--- a/src/content/stories/taken-in.md
+++ b/src/content/stories/taken-in.md
@@ -1,8 +1,7 @@
 ---
 title: Taken In
 pubDate: 2024-01-22
-authors:
-  - Bad Manners
+authors: bad-manners
 wordCount: 5900
 contentWarning: >
   Contains: Non-fatal same size oral vore, with willing male feral hybrid pred (mimic x maned wolf), unwilling PoV anthro prey, and full tour.
@@ -23,7 +22,7 @@ tags:
     "point of view",
   ]
 copyrightedCharacters:
-  "Sam Brendan": Bad Manners
+  "Sam Brendan": bad-manners
 ---
 
 Clank! Shuffle! Crunch! The sounds outside are too loud to be stopped by the walls in your room, and you jolt awake.
diff --git a/src/content/stories/tasting-high-consequences.md b/src/content/stories/tasting-high-consequences.md
index dd4736a..71485d6 100644
--- a/src/content/stories/tasting-high-consequences.md
+++ b/src/content/stories/tasting-high-consequences.md
@@ -1,7 +1,7 @@
 ---
 title: Tasting High Consequences
 pubDate: 2023-04-20
-authors: Bad Manners
+authors: bad-manners
 wordCount: 6000
 contentWarning: >
   Contains: non-fatal oral vore, with willing feral female boar pred, unwilling similar size anthro female moth-dragon hybrid prey, and unwilling micro anthro female serpent prey. Also includes object vore, fantasy combat, and cannabis.
diff --git a/src/content/stories/team-building.md b/src/content/stories/team-building.md
index 8bd5cad..92d2aed 100644
--- a/src/content/stories/team-building.md
+++ b/src/content/stories/team-building.md
@@ -1,7 +1,7 @@
 ---
 title: Team Building
 pubDate: 2024-01-07
-authors: Bad Manners
+authors: bad-manners
 wordCount: 15100
 contentWarning: >
   Contains: Non-fatal same size cock vore and anal vore, with willing male anthro monkey pred, willing male anthro gorilla pred, multiple same-size willing male anthro prey. Also includes casual public mass vore, prey transfer, long-term endosoma, hyper cock and balls, hyper and muscle growth, hyper cum inflation, cock worship, casual public gay sex, size difference play, bench-pressing, and voyeurism.
@@ -33,9 +33,9 @@ tags:
     "gay sex",
     "commission",
   ]
-commissioner: { "YolkMonkey": "https://furaffinity.net/user/Vampire101" }
+commissioner: yolkmonkey
 copyrightedCharacters:
-  Yolk: { "YolkMonkey": "https://furaffinity.net/user/Vampire101" }
+  Yolk: yolkmonkey
 prev: team-effort
 ---
 
diff --git a/src/content/stories/team-effort.md b/src/content/stories/team-effort.md
index 6aecd05..faa85df 100644
--- a/src/content/stories/team-effort.md
+++ b/src/content/stories/team-effort.md
@@ -1,7 +1,7 @@
 ---
 title: Team Effort
 pubDate: 2023-08-08
-authors: Bad Manners
+authors: bad-manners
 wordCount: 11600
 contentWarning: >
   Contains: Non-fatal same size cock vore, with semi-willing to willing male anthro monkey pred, multiple willing male anthro prey, and long-term endosoma. Also includes hyper cock growth, cock worship, hyper cum inflation, public vore, casual gay sex (oral and anal sex; same size, size difference), and public sex.
@@ -27,9 +27,9 @@ tags:
     "gay sex",
     "request",
   ]
-requester: { "YolkMonkey": "https://furaffinity.net/user/Vampire101" }
+requester: yolkmonkey
 copyrightedCharacters:
-  Yolk: { "YolkMonkey": "https://furaffinity.net/user/Vampire101" }
+  Yolk: yolkmonkey
 next: team-building
 ---
 
diff --git a/src/content/stories/the-last-livestream.md b/src/content/stories/the-last-livestream.md
index f87a742..dff415b 100644
--- a/src/content/stories/the-last-livestream.md
+++ b/src/content/stories/the-last-livestream.md
@@ -1,7 +1,7 @@
 ---
 title: The Last Livestream
 pubDate: 2022-12-05
-authors: Bad Manners
+authors: bad-manners
 wordCount: 1400
 contentWarning: >
   Contains: non-fatal similar size unbirth, willing anthro female coatimundi pred, willing anthro female fennec fox prey, masturbation, livestreamed vore.
diff --git a/src/content/stories/the-lost-of-the-marshes/bonus-1-quince-s-fantasy.md b/src/content/stories/the-lost-of-the-marshes/bonus-1-quince-s-fantasy.md
index 4f8d493..cdfb62f 100644
--- a/src/content/stories/the-lost-of-the-marshes/bonus-1-quince-s-fantasy.md
+++ b/src/content/stories/the-lost-of-the-marshes/bonus-1-quince-s-fantasy.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Bonus: Quince's Fantasy"
 shortTitle: "Bonus – Quince's Fantasy"
 pubDate: 2023-01-18
-authors: Bad Manners
+authors: bad-manners
 wordCount: 5800
 contentWarning: >
   Contains: macro and size difference, non-fatal oral vore. Also includes dream scenarios, role reversal, and self-vore.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-1.md b/src/content/stories/the-lost-of-the-marshes/chapter-1.md
index e78e76a..05bfd54 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-1.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-1.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 1: Found"
 shortTitle: "Chapter 1 – Found"
 pubDate: 2022-06-04
-authors: Bad Manners
+authors: bad-manners
 wordCount: 7300
 contentWarning: >
   Contains: macro, non-fatal oral vore.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-10.md b/src/content/stories/the-lost-of-the-marshes/chapter-10.md
index 5016e8c..6ac3b1e 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-10.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-10.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 10: Memory"
 shortTitle: "Chapter 10 – Memory"
 pubDate: 2023-05-27
-authors: Bad Manners
+authors: bad-manners
 wordCount: 14600
 contentWarning: >
   Contains: macro with non-fatal oral vore, anal vore, cock vore, and slit vore. Also includes sexual situations, slight blood, and heavy themes.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-11.md b/src/content/stories/the-lost-of-the-marshes/chapter-11.md
index d5dd01b..432bb7c 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-11.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-11.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 11: Familiar"
 shortTitle: "Chapter 11 – Familiar"
 pubDate: 2023-11-15
-authors: Bad Manners
+authors: bad-manners
 wordCount: 13600
 contentWarning: >
   Contains: non-fatal oral vore and cock vore, with size difference and macro. Also includes gay sexual situations.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-2.md b/src/content/stories/the-lost-of-the-marshes/chapter-2.md
index c305a59..965d16e 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-2.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-2.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 2: Trust"
 shortTitle: "Chapter 2 – Trust"
 pubDate: 2022-06-09
-authors: Bad Manners
+authors: bad-manners
 wordCount: 6900
 contentWarning: >
   Contains: macro, non-fatal oral vore, minor nudity.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-3.md b/src/content/stories/the-lost-of-the-marshes/chapter-3.md
index d5b518b..7ce0c6c 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-3.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-3.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 3: Home"
 shortTitle: "Chapter 3 – Home"
 pubDate: 2022-06-19
-authors: Bad Manners
+authors: bad-manners
 wordCount: 10800
 contentWarning: >
   Contains: macro and size difference, non-fatal oral vore, role reversal.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-4.md b/src/content/stories/the-lost-of-the-marshes/chapter-4.md
index 12cec72..3ab9530 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-4.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-4.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 4: Change"
 shortTitle: "Chapter 4 – Change"
 pubDate: 2022-07-12
-authors: Bad Manners
+authors: bad-manners
 wordCount: 12000
 contentWarning: >
   Contains: size difference, non-fatal oral vore, live feeding, sexual nudity.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-5.md b/src/content/stories/the-lost-of-the-marshes/chapter-5.md
index d8560b7..2708d81 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-5.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-5.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 5: Intersection"
 shortTitle: "Chapter 5 – Intersection"
 pubDate: 2022-08-04
-authors: Bad Manners
+authors: bad-manners
 wordCount: 8600
 contentWarning: >
   Contains: size difference, non-fatal oral vore. Also includes heavy themes.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-6.md b/src/content/stories/the-lost-of-the-marshes/chapter-6.md
index a91dc03..949cbac 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-6.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-6.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 6: Pleasure"
 shortTitle: "Chapter 6 – Pleasure"
 pubDate: 2022-10-22
-authors: Bad Manners
+authors: bad-manners
 wordCount: 9000
 contentWarning: >
   Contains: size difference, non-fatal oral vore and unbirth, gay sex, masturbation.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-7.md b/src/content/stories/the-lost-of-the-marshes/chapter-7.md
index 864ef56..94852f4 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-7.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-7.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 7: Honesty"
 shortTitle: "Chapter 7 – Honesty"
 pubDate: 2022-11-23
-authors: Bad Manners
+authors: bad-manners
 wordCount: 6500
 contentWarning: >
   Contains: macro and size difference, non-fatal oral and anal vore, gay sex.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-8.md b/src/content/stories/the-lost-of-the-marshes/chapter-8.md
index 3b0bb6f..fc5aec0 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-8.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-8.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 8: Estranged"
 shortTitle: "Chapter 8 – Estranged"
 pubDate: 2022-12-08
-authors: Bad Manners
+authors: bad-manners
 wordCount: 7500
 contentWarning: >
   Contains: macro and size difference, non-fatal oral and anal vore.
diff --git a/src/content/stories/the-lost-of-the-marshes/chapter-9.md b/src/content/stories/the-lost-of-the-marshes/chapter-9.md
index 9fae6dc..b379e9c 100644
--- a/src/content/stories/the-lost-of-the-marshes/chapter-9.md
+++ b/src/content/stories/the-lost-of-the-marshes/chapter-9.md
@@ -2,7 +2,7 @@
 title: "The Lost of the Marshes – Chapter 9: Stuck"
 shortTitle: "Chapter 9 – Stuck"
 pubDate: 2023-01-31
-authors: Bad Manners
+authors: bad-manners
 wordCount: 11100
 contentWarning: >
   Contains: macro and size difference, non-fatal oral vore and slit vore. Also includes gay sexual situations, slight vomit, slight blood, and heavy themes.
diff --git a/src/content/stories/tomo-moku.md b/src/content/stories/tomo-moku.md
index 3f25eb0..b6d5b6d 100644
--- a/src/content/stories/tomo-moku.md
+++ b/src/content/stories/tomo-moku.md
@@ -1,7 +1,7 @@
 ---
 title: tomo moku
 pubDate: 2023-04-01
-authors: nasin ike Pemene
+authors: bad-manners
 wordCount: 1200
 contentWarning: >
   nanpa nimi li mute li kijetesantakalu kijetesantakalu kijetesantakalu kijetesantakalu kijetesantakalu. lipu li jo e ijo tu tu ni: moku musi pi moli ala kepeken uta; akesi li moku musi e soweli; akesi li wile e ni; soweli li wile ala e ni.
diff --git a/src/content/stories/trouble-sleeping.md b/src/content/stories/trouble-sleeping.md
index bcf2fc2..c9bf598 100644
--- a/src/content/stories/trouble-sleeping.md
+++ b/src/content/stories/trouble-sleeping.md
@@ -1,7 +1,7 @@
 ---
 title: Trouble Sleeping
 pubDate: 2023-09-20
-authors: Bad Manners
+authors: bad-manners
 wordCount: 4800
 contentWarning: >
   Contains: Non-fatal size difference unbirth, with asleep female anthro wolf pred and willing male feral sparrow prey. Also includes straight sex and accidental long-term endo.
diff --git a/src/content/stories/warped-friendship.md b/src/content/stories/warped-friendship.md
index 096ec0f..4c3d445 100644
--- a/src/content/stories/warped-friendship.md
+++ b/src/content/stories/warped-friendship.md
@@ -1,7 +1,7 @@
 ---
 title: Warped Friendship
 pubDate: 2023-08-08
-authors: Bad Manners
+authors: bad-manners
 wordCount: 7800
 contentWarning: >
   Contains: Non-fatal same size oral vore, with willing male anthro fennec fox pred, unwilling to willing male anthro red panda prey, and long-term endo.
@@ -24,10 +24,10 @@ tags:
     "long-term endo",
     "request",
   ]
-requester: { "Avour Inden": "https://furaffinity.net/user/pppp0000" }
+requester: avour-inden
 copyrightedCharacters:
-  Avour: { "Avour Inden": "https://furaffinity.net/user/pppp0000" }
-  Buster: { "Holi": "https://furaffinity.net/user/CinnamonStars" }
+  Avour: avour-inden
+  Buster: holi
 ---
 
 This wasn't Avour's usual life-or-death assignment, where he had to hunt down supernatural entities. The red panda was skilled in both combat and magic, and had made a name for himself by facing off against formidable foes. But this time, it was just a simple side job. He was on his way to some town that he had never heard of, where there had been several complaints about some sort of pest that kept stealing people's foods and wreaking havoc.
diff --git a/src/content/stories/within-limits.md b/src/content/stories/within-limits.md
index c024a07..47ed56a 100644
--- a/src/content/stories/within-limits.md
+++ b/src/content/stories/within-limits.md
@@ -1,7 +1,7 @@
 ---
 title: Within Limits
 pubDate: 2023-12-05
-authors: Bad Manners
+authors: bad-manners
 wordCount: 14500
 contentWarning: >
   Contains: non-fatal vore, with female taur unbirth (mass vore, hammerspace), male anthro cock vore, and multiple anthro + human willing prey. Also includes bigger prey, similar size prey, size difference, nested vore, prey transfer, hyper genitals, alien genitals, and a sci-fi orgy setting.
@@ -35,9 +35,9 @@ tags:
     "orgy",
     "commission",
   ]
-commissioner: { "Asof Yeun": "https://www.furaffinity.net/user/AsofYeun/" }
+commissioner: asofyeun
 copyrightedCharacters:
-  Ushitora: { "Asof Yeun": "https://www.furaffinity.net/user/AsofYeun/" }
+  Ushitora: asofyeun
 ---
 
 Tonight was going to be Ushitora's big night, but she showed no signs of nervousness. She had no reason to. The holographic readings on her bracelet were all nominal, of course, but she checked on them to make sure that all sensors were on. The technician didn't want to lose a single bit of data for her research.
diff --git a/src/content/stories/you-re-home.md b/src/content/stories/you-re-home.md
index 60c75ea..8c286e9 100644
--- a/src/content/stories/you-re-home.md
+++ b/src/content/stories/you-re-home.md
@@ -1,7 +1,7 @@
 ---
 title: You're Home
 pubDate: 2022-11-10
-authors: Bad Manners
+authors: bad-manners
 wordCount: 11300
 contentWarning: >
   Contains: Unwilling, non-fatal oral vore, with similar size preds/preys, and implied permanent endosoma. Also includes sexual situations and masturbation, slight description of vomit, and a bunch of social anxiety.
diff --git a/src/content/users/asofyeun.json b/src/content/users/asofyeun.json
new file mode 100644
index 0000000..e46d95b
--- /dev/null
+++ b/src/content/users/asofyeun.json
@@ -0,0 +1,11 @@
+{
+  "name": "Asof Yeun",
+  "links": {
+    "eka": "https://aryion.com/g4/user/asofyeun",
+    "furaffinity": "https://www.furaffinity.net/user/asofyeun",
+    "inkbunny": "https://inkbunny.net/asofyeun",
+    "sofurry": "https://asofyeun.sofurry.com/",
+    "weasyl": "https://www.weasyl.com/~asofyeun"
+  },
+  "preferredLink": "furaffinity"
+}
diff --git a/src/content/users/avour-inden.json b/src/content/users/avour-inden.json
new file mode 100644
index 0000000..488adc6
--- /dev/null
+++ b/src/content/users/avour-inden.json
@@ -0,0 +1,7 @@
+{
+  "name": "Avour Inden",
+  "links": {
+    "furaffinity": "https://furaffinity.net/user/pppp0000"
+  },
+  "preferredLink": "furaffinity"
+}
diff --git a/src/content/users/bad-manners.json b/src/content/users/bad-manners.json
new file mode 100644
index 0000000..827f7c1
--- /dev/null
+++ b/src/content/users/bad-manners.json
@@ -0,0 +1,20 @@
+{
+  "name": "Bad Manners",
+  "nameLang": {
+    "eng": "Bad Manners",
+    "tok": "nasin ike Pemene"
+  },
+  "avatar": "/src/assets/images/logo_bm.png",
+  "links": {
+    "website": "https://badmanners.xyz",
+    "eka": ["https://aryion.com/g4/user/BadManners", "BadManners"],
+    "furaffinity": ["https://www.furaffinity.net/user/BadManners", "BadManners"],
+    "inkbunny": ["https://inkbunny.net/BadManners", "BadManners"],
+    "sofurry": ["https://bad-manners.sofurry.com/", "Bad Manners"],
+    "weasyl": ["https://www.weasyl.com/~BadManners", "BadManners"],
+    "twitter": "https://twitter.com/BadManners__",
+    "mastodon": "https://meow.social/@BadManners",
+    "bluesky": "https://bsky.app/profile/badmanners.xyz"
+  },
+  "preferredLink": null
+}
diff --git a/src/content/users/dee-lumeni.json b/src/content/users/dee-lumeni.json
new file mode 100644
index 0000000..4bb00e2
--- /dev/null
+++ b/src/content/users/dee-lumeni.json
@@ -0,0 +1,7 @@
+{
+  "name": "Dee Lumeni",
+  "links": {
+    "eka": ["https://aryion.com/g4/user/KeeperofLillies", "KeeperofLillies"]
+  },
+  "preferredLink": "eka"
+}
diff --git a/src/content/users/holi.json b/src/content/users/holi.json
new file mode 100644
index 0000000..d46dfe2
--- /dev/null
+++ b/src/content/users/holi.json
@@ -0,0 +1,7 @@
+{
+  "name": "Holi",
+  "links": {
+    "furaffinity": ["https://furaffinity.net/user/CinnamonStars", "CinnamonStars"]
+  },
+  "preferredLink": "furaffinity"
+}
diff --git a/src/content/users/scion.json b/src/content/users/scion.json
new file mode 100644
index 0000000..33d6fa3
--- /dev/null
+++ b/src/content/users/scion.json
@@ -0,0 +1,8 @@
+{
+  "name": "Scion",
+  "links": {
+    "eka": "https://aryion.com/g4/user/Scion",
+    "furaffinity": ["https://www.furaffinity.net/user/Scionic", "Scionic"]
+  },
+  "preferredLink": "eka"
+}
diff --git a/src/content/users/yolkmonkey.json b/src/content/users/yolkmonkey.json
new file mode 100644
index 0000000..ac8e279
--- /dev/null
+++ b/src/content/users/yolkmonkey.json
@@ -0,0 +1,8 @@
+{
+  "name": "YolkMonkey",
+  "links": {
+    "furaffinity": ["https://furaffinity.net/user/Vampire101", "Vampire101"],
+    "sofurry": ["https://vampire101.sofurry.com/", "Vampire101"]
+  },
+  "preferredLink": "furaffinity"
+}
diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro
index 5c008c1..bb15d98 100644
--- a/src/layouts/GameLayout.astro
+++ b/src/layouts/GameLayout.astro
@@ -1,6 +1,6 @@
 ---
 import { Image } from "astro:assets";
-import { type CollectionEntry } from "astro:content";
+import { type CollectionEntry, getEntry, getEntries } from "astro:content";
 import { Markdown } from "@astropub/md";
 import { format as formatDate } from "date-fns";
 import { enUS as enUSLocale } from "date-fns/locale/en-US";
@@ -13,8 +13,17 @@ import Prose from "../components/Prose.astro";
 type Props = CollectionEntry<"games">["data"];
 
 const { props } = Astro;
-//const relatedStories = (await Promise.all((props.relatedStories || []).map(story => getEntry(story)))).filter(story => !story.data.isDraft)
-// const relatedGames = (await Promise.all((props.relatedGames || []).map(game => getEntry(game)))).filter(game => !game.data.isDraft)
+const authors = await getEntries([props.authors].flat());
+const copyrightedCharacters: Record<string, CollectionEntry<"users">> = {};
+Object.keys(props.copyrightedCharacters).forEach(async (character) => {
+  copyrightedCharacters[character] = await getEntry(props.copyrightedCharacters[character]);
+});
+// const relatedStories = (await getEntries(props.relatedStories)).filter(
+//   (story) => !story.data.isDraft,
+// );
+// const relatedGames = (await getEntries(props.relatedGames)).filter(
+//   (game) => !game.data.isDraft,
+// );
 ---
 
 <AgeRestrictedBaseLayout pageTitle={props.title}>
@@ -74,7 +83,7 @@ const { props } = Astro;
         id="game-information"
         class="mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200"
       >
-        <Authors authors={props.authors} lang={props.lang} />
+        <Authors authors={authors} lang={props.lang} />
         {
           props.isDraft ? (
             <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
@@ -131,7 +140,7 @@ const { props } = Astro;
         </h2>
         <Prose>
           <Markdown of={props.description} />
-          <CopyrightedCharacters copyrightedCharacters={props.copyrightedCharacters} lang={props.lang} />
+          <CopyrightedCharacters copyrightedCharacters={copyrightedCharacters} lang={props.lang} />
         </Prose>
       </section>
       <div class="pr-3 text-right print:hidden">
diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro
index dc572b9..0a526b1 100644
--- a/src/layouts/StoryLayout.astro
+++ b/src/layouts/StoryLayout.astro
@@ -1,6 +1,6 @@
 ---
 import { Image } from "astro:assets";
-import { getEntry, type CollectionEntry } from "astro:content";
+import { type CollectionEntry, getEntry, getEntries } from "astro:content";
 import { Markdown } from "@astropub/md";
 import { format as formatDate } from "date-fns";
 import { enUS as enUSLocale } from "date-fns/locale/en-US";
@@ -22,10 +22,15 @@ let next = props.next && (await getEntry(props.next));
 if (next && next.data.isDraft) {
   next = undefined;
 }
-const relatedStories = (await Promise.all((props.relatedStories || []).map((story) => getEntry(story)))).filter(
-  (story) => !story.data.isDraft,
-);
-// const relatedGames = (await Promise.all((props.relatedGames || []).map(game => getEntry(game)))).filter(
+const authors = await getEntries([props.authors].flat());
+const commissioner = props.commissioner && (await getEntry(props.commissioner));
+const requester = props.requester && (await getEntry(props.requester));
+const copyrightedCharacters: Record<string, CollectionEntry<"users">> = {};
+Object.keys(props.copyrightedCharacters).forEach(async (character) => {
+  copyrightedCharacters[character] = await getEntry(props.copyrightedCharacters[character]);
+});
+const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
+// const relatedGames = (await getEntries(props.relatedGames)).filter(
 //   (game) => !game.data.isDraft,
 // );
 ---
@@ -122,7 +127,7 @@ const relatedStories = (await Promise.all((props.relatedStories || []).map((stor
         id="story-information"
         class="mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200"
       >
-        <Authors authors={props.authors} lang={props.lang} />
+        <Authors authors={authors} lang={props.lang} />
         {
           props.isDraft ? (
             <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
@@ -131,16 +136,16 @@ const relatedStories = (await Promise.all((props.relatedStories || []).map((stor
           ) : null
         }
         {
-          props.commissioner && (
+          commissioner && (
             <p id="commissioner">
-              Commissioned by <UserComponent user={props.commissioner} />
+              Commissioned by <UserComponent user={commissioner} lang={props.lang} />
             </p>
           )
         }
         {
-          props.requester && (
+          requester && (
             <p id="requester">
-              Requested by <UserComponent user={props.requester} />
+              Requested by <UserComponent user={requester} lang={props.lang} />
             </p>
           )
         }
@@ -196,7 +201,7 @@ const relatedStories = (await Promise.all((props.relatedStories || []).map((stor
         </h2>
         <Prose>
           <Markdown of={props.description} />
-          <CopyrightedCharacters copyrightedCharacters={props.copyrightedCharacters} lang={props.lang} />
+          <CopyrightedCharacters copyrightedCharacters={copyrightedCharacters} lang={props.lang} />
         </Prose>
       </section>
       <div class="pr-3 text-right print:hidden">
@@ -281,7 +286,9 @@ const relatedStories = (await Promise.all((props.relatedStories || []).map((stor
     </main>
     <div class="pt-6 text-center text-xs text-black dark:text-white">
       <span>&copy; {formatDate(props.pubDate, "yyyy")} | </span>
-      <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank">Licenses</a>
+      <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
+        >{props.lang === "eng" ? "Licenses" : props.lang === "tok" ? "lipu lawa" : null}</a
+      >
     </div>
   </div>
 </AgeRestrictedBaseLayout>
diff --git a/src/pages/_stories.astro b/src/pages/_stories.astro
deleted file mode 100644
index a4546b2..0000000
--- a/src/pages/_stories.astro
+++ /dev/null
@@ -1,36 +0,0 @@
----
-import { Image } from "astro:assets";
-import { getCollection } from "astro:content";
-import { getUnixTime } from "date-fns";
-import GalleryLayout from "../layouts/GalleryLayout.astro";
-
-const stories = (await getCollection("stories"))
-  .filter((story) => !story.data.isDraft)
-  .sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
----
-
-<GalleryLayout pageTitle="Stories">
-  <h1>Stories</h1>
-  <p>Lorem ipsum.</p>
-  <ul>
-    {
-      stories.map((story) => (
-        <li>
-          <a href={`/stories/${story.slug}`}>
-            {story.data.thumbnail && (
-              <Image
-                src={story.data.thumbnail}
-                alt={`Thumbnail for ${story.data.title}`}
-                width={story.data.thumbnailWidth}
-                height={story.data.thumbnailHeight}
-              />
-            )}
-            <span>
-              {story.data.pubDate} - {story.data.title}
-            </span>
-          </a>
-        </li>
-      ))
-    }
-  </ul>
-</GalleryLayout>
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index b90a548..d28ace5 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -8,11 +8,11 @@ type FeedItem = RSSFeedItem & {
 };
 
 export const GET: APIRoute = async ({ site }) => {
-  const stories = (await getCollection("stories")).filter((story) => !story.data.isDraft);
-  const games = (await getCollection("games")).filter((game) => !game.data.isDraft);
+  const stories = await getCollection("stories", (story) => !story.data.isDraft);
+  const games = await getCollection("games", (game) => !game.data.isDraft);
   return rss({
     title: "Gallery | Bad Manners",
-    description: "Stories, games, and more by Bad Manners",
+    description: "Stories, games, and (possibly) more by Bad Manners",
     site: site as URL,
     items: [
       stories.map<FeedItem>((story) => ({
@@ -21,7 +21,7 @@ export const GET: APIRoute = async ({ site }) => {
         link: `/stories/${story.slug}`,
         description:
           `Word count: ${story.data.wordCount}. ${story.data.contentWarning} ${story.data.descriptionPlaintext || story.data.description}`
-            .replaceAll(/\n+|  +/g, " ")
+            .replaceAll(/[\n ]+/g, " ")
             .trim(),
         categories: ["story"],
       })),
@@ -30,7 +30,7 @@ export const GET: APIRoute = async ({ site }) => {
         pubDate: addHours(game.data.pubDate, 12),
         link: `/games/${game.slug}`,
         description: `${game.data.contentWarning} ${game.data.descriptionPlaintext || game.data.description}`
-          .replaceAll(/\n+|  +/g, " ")
+          .replaceAll(/[\n ]+/g, " ")
           .trim(),
         categories: ["game"],
       })),
diff --git a/src/pages/games.astro b/src/pages/games.astro
index 88f2ef0..49d47b4 100644
--- a/src/pages/games.astro
+++ b/src/pages/games.astro
@@ -5,9 +5,9 @@ import { getUnixTime, format as formatDate } from "date-fns";
 import { enUS as enUSLocale } from "date-fns/locale/en-US";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
 
-const games = (await getCollection("games"))
-  .filter((game) => !game.data.isDraft)
-  .sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
+const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
+  (a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate),
+);
 ---
 
 <GalleryLayout pageTitle="Games">
@@ -18,7 +18,7 @@ const games = (await getCollection("games"))
       games.map((game) => (
         <li>
           <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
-            {game.data.thumbnail && (
+            {game.data.thumbnail ? (
               <Image
                 class="max-w-72"
                 src={game.data.thumbnail}
@@ -26,7 +26,7 @@ const games = (await getCollection("games"))
                 width={game.data.thumbnailWidth}
                 height={game.data.thumbnailHeight}
               />
-            )}
+            ) : null}
             <div class="max-w-72 text-sm">
               <>
                 <span>{game.data.title}</span>
diff --git a/src/pages/games/[...slug].astro b/src/pages/games/[...slug].astro
index 2ff362e..3c8d7bd 100644
--- a/src/pages/games/[...slug].astro
+++ b/src/pages/games/[...slug].astro
@@ -1,20 +1,21 @@
 ---
+import type { GetStaticPaths } from "astro";
 import { type CollectionEntry, getCollection } from "astro:content";
 import GameLayout from "../../layouts/GameLayout.astro";
 
-export async function getStaticPaths() {
+export const getStaticPaths: GetStaticPaths = async () => {
   const games = await getCollection("games");
-  return games.map((story) => ({
-    params: { slug: story.slug },
-    props: story,
+  return games.map((game) => ({
+    params: { slug: game.slug },
+    props: game,
   }));
-}
+};
 type Props = CollectionEntry<"games">;
 
-const story = Astro.props;
-const { Content } = await story.render();
+const game = Astro.props;
+const { Content } = await game.render();
 ---
 
-<GameLayout {...story.data}>
+<GameLayout {...game.data}>
   <Content />
 </GameLayout>
diff --git a/src/pages/stories/[...slug].astro b/src/pages/stories/[...slug].astro
index cabf5d9..d66aff7 100644
--- a/src/pages/stories/[...slug].astro
+++ b/src/pages/stories/[...slug].astro
@@ -1,14 +1,15 @@
 ---
+import type { GetStaticPaths } from "astro";
 import { type CollectionEntry, getCollection } from "astro:content";
 import StoryLayout from "../../layouts/StoryLayout.astro";
 
-export async function getStaticPaths() {
+export const getStaticPaths: GetStaticPaths = async () => {
   const stories = await getCollection("stories");
   return stories.map((story) => ({
     params: { slug: story.slug },
     props: story,
   }));
-}
+};
 type Props = CollectionEntry<"stories">;
 
 const story = Astro.props;
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index 6202d7d..cb0cf57 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -1,17 +1,23 @@
 ---
-import type { GetStaticPathsOptions } from "astro";
+import type { GetStaticPaths, Page } from "astro";
 import { Image } from "astro:assets";
 import { getCollection } from "astro:content";
 import { getUnixTime, format as formatDate } from "date-fns";
 import { enUS as enUSLocale } from "date-fns/locale/en-US";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
+import type { CollectionEntry } from "astro:content";
 
-export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
-  const stories = (await getCollection("stories"))
-    .filter((story) => !story.data.isDraft)
-    .sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
+export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
+  const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort(
+    (a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate),
+  );
   return paginate(stories, { pageSize: 30 });
-}
+};
+
+type Props = {
+  page: Page<CollectionEntry<"stories">>;
+};
+
 const { page } = Astro.props;
 const totalPages = Math.ceil(page.total / page.size);
 ---
@@ -60,7 +66,7 @@ const totalPages = Math.ceil(page.total / page.size);
       page.data.map((story) => (
         <li class="break-inside-avoid">
           <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
-            {story.data.thumbnail && (
+            {story.data.thumbnail ? (
               <Image
                 class="w-48"
                 src={story.data.thumbnail}
@@ -68,7 +74,7 @@ const totalPages = Math.ceil(page.total / page.size);
                 width={story.data.thumbnailWidth}
                 height={story.data.thumbnailHeight}
               />
-            )}
+            ) : null}
             <div class="max-w-48 text-sm">
               <>
                 <span>{story.data.title}</span>
diff --git a/src/pages/stories/export/[website]/[...slug].ts b/src/pages/stories/export/[website]/[...slug].ts
new file mode 100644
index 0000000..6a57e88
--- /dev/null
+++ b/src/pages/stories/export/[website]/[...slug].ts
@@ -0,0 +1,274 @@
+import type { APIRoute, GetStaticPaths } from "astro";
+import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
+import { marked, type RendererApi } from "marked";
+import he from "he";
+import { type Website } from "../../../../content/config";
+
+const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[];
+
+type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<infer K> ? K : never;
+
+const bbcodeRenderer: RendererApi = {
+  strong: (text) => `[b]${text}[/b]`,
+  em: (text) => `[i]${text}[/i]`,
+  codespan: (code) => code,
+  br: () => `\n\n`,
+  link: (href, _, text) => `[url=${href}]${text}[/url]`,
+  image: (href) => `[img]${href}[/img]`,
+  text: (text) => text,
+  paragraph: (text) => `\n${text}\n`,
+  list: (body, ordered) => (ordered ? `\n[ol]\n${body}[/ol]\n` : `\n[ul]\n${body}[/ul]\n`),
+  listitem: (text) => `[li]${text}[/li]\n`,
+  blockquote: (quote) => `\n[quote]${quote}[/quote]\n`,
+  code: (code) => `\n[code]${code}[/code]\n`,
+  heading: (heading) => `\n${heading}\n`,
+  table: (header, body) => `\n[table]\n${header}${body}[/table]\n`,
+  tablerow: (content) => `[tr]\n${content}[/tr]\n`,
+  tablecell: (content, { header }) => (header ? `[th]${content}[/th]\n` : `[td]${content}[/td]\n`),
+  hr: () => `\n===\n`,
+  del: () => {
+    throw new Error("Not supported by bbcodeRenderer: del");
+  },
+  html: () => {
+    throw new Error("Not supported by bbcodeRenderer: html");
+  },
+  checkbox: () => {
+    throw new Error("Not supported by bbcodeRenderer: checkbox");
+  },
+};
+
+function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
+  const link = user.data.links[website];
+  if (link) {
+    if (typeof link === "string") {
+      switch (website) {
+        case "website":
+          break;
+        case "eka":
+          const ekaMatch = link.match(/^.*\baryion\.com\/g4\/user\/([^\/]+)\/?$/);
+          if (ekaMatch && ekaMatch[1]) {
+            return ekaMatch[1];
+          }
+          break;
+        case "furaffinity":
+          const faMatch = link.match(/^.*\bfuraffinity\.net\/user\/([^\/]+)\/?$/);
+          if (faMatch && faMatch[1]) {
+            return faMatch[1];
+          }
+          break;
+        case "inkbunny":
+          const ibMatch = link.match(/^.*\binkbunny\.net\/([^\/]+)\/?$/);
+          if (ibMatch && ibMatch[1]) {
+            return ibMatch[1];
+          }
+          break;
+        case "sofurry":
+          const sfMatch = link.match(/^(?:https?:\/\/)?([^\.]+).sofurry.com\b.*$/);
+          if (sfMatch && sfMatch[1]) {
+            return sfMatch[1].replaceAll("-", " ");
+          }
+          break;
+        case "weasyl":
+          const weasylMatch = link.match(/^.*\bweasyl\.com\/\~([^\/]+)\/?$/);
+          if (weasylMatch && weasylMatch[1]) {
+            return weasylMatch[1];
+          }
+          break;
+        case "twitter":
+          const twitterMatch = link.match(/^.*(?:\btwitter\.com|\bx\.com)\/@?([^\/]+)\/?$/);
+          if (twitterMatch && twitterMatch[1]) {
+            return twitterMatch[1];
+          }
+          break;
+        case "mastodon":
+          const mastodonMatch = link.match(/^(?:https?\:\/\/)?([^\/]+)\/(?:@|users\/)([^\/]+)\/?$/);
+          if (mastodonMatch && mastodonMatch[1] && mastodonMatch[2]) {
+            return `${mastodonMatch[2]}@${mastodonMatch[1]}`;
+          }
+          break;
+        case "bluesky":
+          const bskyMatch = link.match(/^.*\bbsky\.app\/profile\/([^\/]+)\/?$/);
+          if (bskyMatch && bskyMatch[1]) {
+            return bskyMatch[1];
+          }
+          break;
+        default:
+          throw new Error(`Unhandled website "${website}" in getUsernameForWebsite`);
+      }
+    } else {
+      return link[1].replace(/^@/, "");
+    }
+  }
+  throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
+}
+
+function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite): string {
+  switch (website) {
+    case "eka":
+      if (user.data.links.eka) {
+        return `:icon${getUsernameForWebsite(user, "eka")}:`;
+      }
+      break;
+    case "furaffinity":
+      if (user.data.links.furaffinity) {
+        return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
+      }
+      break;
+    case "weasyl":
+      if (user.data.links.weasyl) {
+        return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
+      } else if (
+        user.data.links.furaffinity &&
+        !(["inkbunny", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
+      ) {
+        return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`;
+      } else if (
+        user.data.links.inkbunny &&
+        !(["furaffinity", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
+      ) {
+        return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
+      } else if (
+        user.data.links.sofurry &&
+        !(["furaffinity", "inkbunny"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
+      ) {
+        return `<sf:${getUsernameForWebsite(user, "sofurry")}>`;
+      }
+      break;
+    case "inkbunny":
+      if (user.data.links.inkbunny) {
+        return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
+      } else if (
+        user.data.links.furaffinity &&
+        !(["sofurry", "weasyl"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
+      ) {
+        return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
+      } else if (
+        user.data.links.sofurry &&
+        !(["furaffinity", "weasyl"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
+      ) {
+        return `[sf]${getUsernameForWebsite(user, "sofurry")}[/sf]`;
+      } else if (
+        user.data.links.weasyl &&
+        !(["furaffinity", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
+      ) {
+        return `[weasyl]${getUsernameForWebsite(user, "weasyl")}[/weasyl]`;
+      }
+      break;
+    case "sofurry":
+      if (user.data.links.sofurry) {
+        return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
+      } else if (
+        user.data.links.furaffinity &&
+        !(["inkbunny"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
+      ) {
+        return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
+      } else if (
+        user.data.links.inkbunny &&
+        !(["furaffinity"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
+      ) {
+        return `ib!${getUsernameForWebsite(user, "inkbunny")}`;
+      }
+      break;
+    default:
+      throw new Error(`Unhandled website "${website}" in getLinkForUser`);
+  }
+  if (user.data.preferredLink) {
+    if (user.data.preferredLink in user.data.links) {
+      const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
+      return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
+    } else {
+      throw new Error(`No preferredLink "${user.data.preferredLink}" for user ${user.id}`);
+    }
+  }
+  throw new Error(`No "${website}"-supported link for user "${user.id}" without preferredLink`);
+}
+
+type Props = {
+  story: CollectionEntry<"stories">;
+};
+
+type Params = {
+  website: ExportWebsite;
+  slug: CollectionEntry<"stories">["slug"];
+};
+
+export const getStaticPaths: GetStaticPaths = async () => {
+  if (import.meta.env.PROD) {
+    return [];
+  }
+  return (await getCollection("stories"))
+    .map((story) =>
+      WEBSITE_LIST.map((website) => ({
+        params: { website, slug: story.slug } satisfies Params,
+        props: { story } satisfies Props,
+      })),
+    )
+    .flat();
+};
+
+export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: { website }, site }) => {
+  const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website);
+
+  if (
+    story.data.copyrightedCharacters &&
+    "" in story.data.copyrightedCharacters &&
+    Object.keys(story.data.copyrightedCharacters).length > 1
+  ) {
+    throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
+  }
+  const charactersPerUser =
+    story.data.copyrightedCharacters &&
+    Object.keys(story.data.copyrightedCharacters).reduce(
+      (acc, character) => {
+        const key = story.data.copyrightedCharacters[character].id;
+        if (!(key in acc)) {
+          acc[key] = [];
+        }
+        acc[key].push(character);
+        return acc;
+      },
+      {} as Record<
+        CollectionEntry<"users">["id"],
+        (typeof story.data.copyrightedCharacters extends Record<infer K, any> ? K : never)[]
+      >,
+    );
+
+  let storyDescription = (
+    [
+      story.data.description,
+      `*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`,
+      "Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" , "),
+      story.data.requester && "Request for: " + u(await getEntry(story.data.requester)),
+      story.data.commissioner && "Commissioned by: " + u(await getEntry(story.data.commissioner)),
+      ...(await Promise.all(
+        (Object.keys(charactersPerUser) as CollectionEntry<"users">["id"][]).map(async (id) => {
+          const user = u(await getEntry("users", id));
+          const characterList = charactersPerUser[id];
+          if (characterList[0] == "") {
+            return `All characters are © ${user}`;
+          } else if (characterList.length > 2) {
+            return `${characterList.slice(0, characterList.length - 1).join(", ")}, and ${characterList[characterList.length - 1]} are © ${user}`;
+          } else if (characterList.length > 1) {
+            return `${characterList[0]} and ${characterList[1]} are © ${user}`;
+          }
+          return `${characterList[0]} is © ${user}`;
+        }),
+      )),
+    ].filter((data) => data) as string[]
+  )
+    .join("\n\n")
+    .replaceAll(
+      /\[([^\]]+)\]\((\.[^\)]+)\)/g,
+      (_, group1, group2) => `[${group1}](${new URL(group2, new URL(`/stories/${story.slug}`, site)).toString()})`,
+    );
+  const headers = { "Content-Type": "text/markdown; charset=utf-8" };
+  // BBCode exports
+  if ((["eka", "furaffinity", "inkbunny", "sofurry"] satisfies ExportWebsite[] as ExportWebsite[]).includes(website)) {
+    storyDescription = he.decode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription));
+    headers["Content-Type"] = "text/plain; charset=utf-8";
+    // Markdown exports (no-op)
+  } else if (!(["weasyl"] satisfies ExportWebsite[] as ExportWebsite[]).includes(website)) {
+    return new Response(null, { status: 404 });
+  }
+  return new Response(`${storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()}\n`, { headers });
+};
diff --git a/src/pages/stories/the-lost-of-the-marshes.astro b/src/pages/stories/the-lost-of-the-marshes.astro
index 4aec512..a2487d6 100644
--- a/src/pages/stories/the-lost-of-the-marshes.astro
+++ b/src/pages/stories/the-lost-of-the-marshes.astro
@@ -5,7 +5,8 @@ import { getUnixTime } from "date-fns";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
 import mapImage from "../../assets/images/tlotm_map.jpg";
 
-const stories = (await getCollection("stories")).filter(
+const stories = await getCollection(
+  "stories",
   (story) => !story.data.isDraft && story.slug.startsWith("the-lost-of-the-marshes/"),
 );
 const mainChapters = stories
@@ -50,7 +51,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
         mainChapters.map((story) => (
           <li class="break-inside-avoid">
             <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
-              {story.data.thumbnail && (
+              {story.data.thumbnail ? (
                 <Image
                   class="w-48"
                   src={story.data.thumbnail}
@@ -58,7 +59,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
                   width={story.data.thumbnailWidth}
                   height={story.data.thumbnailHeight}
                 />
-              )}
+              ) : null}
               <div class="max-w-48 text-sm">{story.data.title}</div>
             </a>
           </li>
@@ -73,7 +74,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
         bonusChapters.map((story) => (
           <li class="break-inside-avoid">
             <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
-              {story.data.thumbnail && (
+              {story.data.thumbnail ? (
                 <Image
                   class="w-48"
                   src={story.data.thumbnail}
@@ -81,7 +82,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
                   width={story.data.thumbnailWidth}
                   height={story.data.thumbnailHeight}
                 />
-              )}
+              ) : null}
               <div class="max-w-48 text-sm">{story.data.title}</div>
             </a>
           </li>
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 4f34dfa..900080f 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -3,47 +3,46 @@ import { getCollection } from "astro:content";
 import { slug } from "github-slugger";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
 
-const [stories, games] = await Promise.all([getCollection("stories"), getCollection("games")]);
+const [stories, games] = await Promise.all([
+  getCollection("stories", (story) => !story.data.isDraft),
+  getCollection("games", (game) => !game.data.isDraft),
+]);
 const tagsSet = new Set<string>();
 const seriesList: Record<string, string> = {};
-stories
-  .filter((story) => !story.data.isDraft)
-  .forEach((story) => {
-    story.data.tags.forEach((tag) => {
-      tagsSet.add(tag);
-    });
-    if (story.data.series) {
-      const [series, url] = Object.entries(story.data.series)[0];
-      if (seriesList[series]) {
-        if (seriesList[series] !== url) {
-          throw new Error(
-            `Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
-          );
-        }
-      } else {
-        seriesList[series] = url;
-      }
-    }
+stories.forEach((story) => {
+  story.data.tags.forEach((tag) => {
+    tagsSet.add(tag);
   });
-games
-  .filter((game) => !game.data.isDraft)
-  .forEach((game) => {
-    game.data.tags.forEach((tag) => {
-      tagsSet.add(tag);
-    });
-    if (game.data.series) {
-      const [series, url] = Object.entries(game.data.series)[0];
-      if (seriesList[series]) {
-        if (seriesList[series] !== url) {
-          throw new Error(
-            `Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
-          );
-        }
-      } else {
-        seriesList[series] = url;
+  if (story.data.series) {
+    const [series, url] = Object.entries(story.data.series)[0];
+    if (seriesList[series]) {
+      if (seriesList[series] !== url) {
+        throw new Error(
+          `Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
+        );
       }
+    } else {
+      seriesList[series] = url;
     }
+  }
+});
+games.forEach((game) => {
+  game.data.tags.forEach((tag) => {
+    tagsSet.add(tag);
   });
+  if (game.data.series) {
+    const [series, url] = Object.entries(game.data.series)[0];
+    if (seriesList[series]) {
+      if (seriesList[series] !== url) {
+        throw new Error(
+          `Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
+        );
+      }
+    } else {
+      seriesList[series] = url;
+    }
+  }
+});
 
 const categorizedTags: Record<string, string[]> = {
   "Types of vore": [
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index af8976f..9ad3175 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -1,11 +1,12 @@
 ---
+import type { GetStaticPaths } from "astro";
 import { Image } from "astro:assets";
 import { type CollectionEntry, getCollection } from "astro:content";
 import { slug } from "github-slugger";
 import { getUnixTime } from "date-fns";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
 
-export async function getStaticPaths() {
+export const getStaticPaths: GetStaticPaths = async () => {
   const [stories, games] = await Promise.all([getCollection("stories"), getCollection("games")]);
   const tags = new Set<string>();
   stories.forEach((story) => {
@@ -32,7 +33,7 @@ export async function getStaticPaths() {
           .sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)),
       },
     }));
-}
+};
 
 type Props = {
   tag: string;
@@ -55,7 +56,7 @@ const { tag, stories, games } = Astro.props;
           {stories.map((story) => (
             <li class="break-inside-avoid">
               <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
-                {story.data.thumbnail && (
+                {story.data.thumbnail ? (
                   <Image
                     class="w-48"
                     src={story.data.thumbnail}
@@ -63,7 +64,7 @@ const { tag, stories, games } = Astro.props;
                     width={story.data.thumbnailWidth}
                     height={story.data.thumbnailHeight}
                   />
-                )}
+                ) : null}
                 <div class="max-w-48 text-sm">{story.data.title}</div>
               </a>
             </li>
@@ -82,7 +83,7 @@ const { tag, stories, games } = Astro.props;
           {games.map((game) => (
             <li class="break-inside-avoid">
               <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
-                {game.data.thumbnail && (
+                {game.data.thumbnail ? (
                   <Image
                     class="w-48"
                     src={game.data.thumbnail}
@@ -90,7 +91,7 @@ const { tag, stories, games } = Astro.props;
                     width={game.data.thumbnailWidth}
                     height={game.data.thumbnailHeight}
                   />
-                )}
+                ) : null}
                 <div class="max-w-48 text-sm">{game.data.title}</div>
               </a>
             </li>