diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..f150f24
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+src/components/DarkModeScript.astro
diff --git a/package-lock.json b/package-lock.json
index 2eb806b..cfbeb1e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,13 +13,11 @@
         "@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",
+        "tiny-decode": "^0.1.3",
         "typescript": "^5.4.2"
       },
       "devDependencies": {
@@ -1336,11 +1334,6 @@
         "@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",
@@ -2357,15 +2350,6 @@
         "node": ">=4"
       }
     },
-    "node_modules/date-fns": {
-      "version": "3.5.0",
-      "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.5.0.tgz",
-      "integrity": "sha512-a+DwyXn7NOfdJireCzAA0B9p7jIXEu/Q9JKCyMYvH6+0vPUNbQceA0neXrdfJ/xzl3mhOh5vibQQ3936Tssm6A==",
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/kossnocorp"
-      }
-    },
     "node_modules/debug": {
       "version": "4.3.4",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -3149,14 +3133,6 @@
         "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",
@@ -6629,6 +6605,14 @@
         "node": ">=0.8"
       }
     },
+    "node_modules/tiny-decode": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/tiny-decode/-/tiny-decode-0.1.3.tgz",
+      "integrity": "sha512-1z+tXaZpPUyREOfjKDQj5lR6HfD6Pa4NF7pb/9ep7sP4+X5WF76bGdJktWCY1Rm+aMR46vJ75VAL/oAptpD1AA==",
+      "dependencies": {
+        "entities": "^4.4.0"
+      }
+    },
     "node_modules/to-fast-properties": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
diff --git a/package.json b/package.json
index 214e963..d765125 100644
--- a/package.json
+++ b/package.json
@@ -17,13 +17,11 @@
     "@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",
+    "tiny-decode": "^0.1.3",
     "typescript": "^5.4.2"
   },
   "devDependencies": {
diff --git a/src/components/AgeRestrictedModal.astro b/src/components/AgeRestrictedModal.astro
index d491e18..764f2c1 100644
--- a/src/components/AgeRestrictedModal.astro
+++ b/src/components/AgeRestrictedModal.astro
@@ -1,10 +1,14 @@
-<Fragment>
+---
+
+---
+
+<template id="template-modal-age-restricted">
   <div
     id="modal-age-restricted"
-    class="fixed inset-0 hidden bg-stone-100 dark:bg-stone-900"
+    class="fixed inset-0 bg-stone-100 dark:bg-stone-900"
     tabindex="-1"
     role="dialog"
-    aria-hidden="true"
+    aria-hidden="false"
   >
     <div class="mx-auto flex min-h-screen max-w-3xl flex-col items-center justify-center text-center tracking-tight">
       <div class="h-14 w-14 text-bm-500 sm:h-16 sm:w-16 dark:text-bm-400">
@@ -44,32 +48,33 @@
       </div>
     </div>
   </div>
-  <script is:inline data-astro-rerun>
-    (function () {
-      if (localStorage.getItem("ageVerified") !== "true") {
-        const modal = document.querySelector("#modal-age-restricted");
-        const rejectButton = modal.querySelector("button[data-modal-reject]");
-        const acceptButton = modal.querySelector("button[data-modal-accept]");
-        function onRejectButtonClick(e) {
-          e.preventDefault();
-          location.href = "about:blank";
-        }
-        function onAcceptButtonClick(e) {
-          e.preventDefault();
-          rejectButton.removeEventListener("click", onRejectButtonClick);
-          acceptButton.removeEventListener("click", onAcceptButtonClick);
-          localStorage.setItem("ageVerified", "true");
-          document.body.style = "overflow:auto;";
-          modal.classList.add("hidden");
-          modal.setAttribute("aria-hidden", "true");
-        }
-        rejectButton.addEventListener("click", onRejectButtonClick);
-        acceptButton.addEventListener("click", onAcceptButtonClick);
-        document.body.style = "overflow:hidden;";
-        modal.setAttribute("aria-hidden", "false");
-        modal.classList.remove("hidden");
-        rejectButton.focus();
+</template>
+
+<script>
+  (function () {
+    if (localStorage.getItem("ageVerified") !== "true") {
+      document.body.appendChild(
+        (document.getElementById("template-modal-age-restricted") as HTMLTemplateElement).content.cloneNode(true),
+      );
+      const modal = document.querySelector<HTMLDivElement>("body > #modal-age-restricted")!;
+      const rejectButton = modal.querySelector<HTMLButtonElement>("button[data-modal-reject]")!;
+      const acceptButton = modal.querySelector<HTMLButtonElement>("button[data-modal-accept]")!;
+      function onRejectButtonClick(e: MouseEvent) {
+        e.preventDefault();
+        location.href = "about:blank";
       }
-    })();
-  </script>
-</Fragment>
+      function onAcceptButtonClick(e: MouseEvent) {
+        e.preventDefault();
+        rejectButton.removeEventListener("click", onRejectButtonClick);
+        acceptButton.removeEventListener("click", onAcceptButtonClick);
+        localStorage.setItem("ageVerified", "true");
+        document.body.style.overflow = "auto";
+        modal.remove();
+      }
+      rejectButton.addEventListener("click", onRejectButtonClick);
+      acceptButton.addEventListener("click", onAcceptButtonClick);
+      document.body.style.overflow = "hidden";
+      rejectButton.focus();
+    }
+  })();
+</script>
diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro
index cce2332..3abd902 100644
--- a/src/components/DarkModeScript.astro
+++ b/src/components/DarkModeScript.astro
@@ -2,27 +2,23 @@
 
 ---
 
-<script is:inline data-astro-rerun>
+<script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script>
+
+<script>
   (function () {
     var colorScheme = localStorage.getItem("colorScheme");
     if (colorScheme == null || colorScheme === "auto") {
-      localStorage.setItem("colorScheme", "auto");
       colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
     }
-    const bodyClassList = document.body.classList;
-    if (colorScheme === "dark") {
-      bodyClassList.add("dark");
-    }
-
     document.querySelectorAll("button[data-dark-mode]").forEach(function (button) {
       button.addEventListener("click", function (e) {
         e.preventDefault();
         if (colorScheme === "dark") {
           colorScheme = "light";
-          bodyClassList.remove("dark");
+          document.body.classList.remove("dark");
         } else {
           colorScheme = "dark";
-          bodyClassList.add("dark");
+          document.body.classList.add("dark");
         }
         localStorage.setItem("colorScheme", colorScheme);
       });
diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro
new file mode 100644
index 0000000..653e11b
--- /dev/null
+++ b/src/components/MastodonComments.astro
@@ -0,0 +1,220 @@
+---
+type Props = {
+  instance?: string;
+  user?: string;
+  postId?: string;
+};
+
+const { instance, user, postId } = Astro.props;
+---
+
+<section
+  id="comment-section"
+  class="hidden px-2 font-serif"
+  aria-describedby="title-comment-section"
+  data-instance={instance || ""}
+  data-user={user || ""}
+  data-post-id={postId || ""}
+>
+  <h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
+    Comments
+  </h2>
+  <div class="text-stone-800 dark:text-stone-100" id="comments">
+    <button
+      class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900"
+      id="load-comments-button"
+      data-load-comments
+    >
+      <span>Click to load comments</span>
+    </button>
+  </div>
+</section>
+
+<template id="template-comments-loading">
+  <svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
+    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+    <path
+      class="opacity-100"
+      fill="currentColor"
+      d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+    ></path>
+  </svg>
+  <span>Loading...</span>
+</template>
+
+<template id="template-comment-box">
+  <div class="my-2 rounded-md border-2 border-stone-400 bg-stone-200 p-2 dark:border-stone-600 dark:bg-stone-800">
+    <div class="ml-1">
+      <a data-author class="text-link flex items-center text-lg hover:underline focus:underline">
+        <img data-avatar class="mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
+        <span data-display-name></span>
+      </a>
+      <a data-post-link class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline">
+        <span class="mr-1" data-publish-date></span>
+      </a>
+    </div>
+    <div data-content class="prose-a:text-link prose prose-story my-1 dark:prose-invert prose-img:my-0"></div>
+    <div class="ml-1 flex flex-row pb-2 pt-1">
+      <div class="flex" aria-label="Favorites">
+        <span data-favorites></span>
+        <svg class="ml-2 w-5 fill-current" viewBox="0 0 576 512" aria-hidden>
+          <path
+            d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"
+          ></path>
+        </svg>
+      </div>
+      <div class="ml-4 flex" aria-label="Reblogs">
+        <span data-reblogs></span>
+        <svg class="ml-2 w-5 fill-current" viewBox="0 0 512 512" aria-hidden>
+          <path
+            d="M0 224c0 17.7 14.3 32 32 32s32-14.3 32-32c0-53 43-96 96-96H320v32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S320 19.1 320 32V64H160C71.6 64 0 135.6 0 224zm512 64c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 53-43 96-96 96H192V352c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6V448H352c88.4 0 160-71.6 160-160z"
+          ></path>
+        </svg>
+      </div>
+    </div>
+    <div data-comment-thread class="-mb-2"></div>
+  </div>
+</template>
+
+<script>
+  interface Emoji {
+    shortcode: string;
+    url: string;
+    static_url: string;
+  }
+
+  interface Comment {
+    id: string;
+    in_reply_to_id: string;
+    url: string;
+    favourites_count: number;
+    reblogs_count: number;
+    created_at: string;
+    edited_at: string | null;
+    account: {
+      username: string;
+      acct: string;
+      display_name: string;
+      url: string;
+      avatar: string;
+      avatar_static: string;
+      emojis: Emoji[];
+    };
+    content: string;
+    emojis: Emoji[];
+  }
+
+  (function () {
+    const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) =>
+      emojis.reduce(
+        (acc, emoji) =>
+          acc.replaceAll(
+            `:${emoji.shortcode}:`,
+            `<img class="${imgClass}" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
+          ),
+        text,
+      );
+
+    const commentSection = document.querySelector<Element>("#comment-section")!;
+    const instance = commentSection.getAttribute("data-instance");
+    const user = commentSection.getAttribute("data-user");
+    const postId = commentSection.getAttribute("data-post-id");
+    if (instance && user && postId) {
+      commentSection.classList.remove("hidden");
+      commentSection.querySelector<HTMLButtonElement>("button[data-load-comments]")!.addEventListener("click", (e) => {
+        e.preventDefault();
+        const loadCommentsButton = e.target as HTMLButtonElement;
+        loadCommentsButton.setAttribute("disabled", "true");
+        loadCommentsButton.replaceChildren(
+          (document.getElementById("template-comments-loading") as HTMLTemplateElement).content.cloneNode(true),
+        );
+        const renderComments = async () => {
+          try {
+            if (!instance || !user || !postId) {
+              throw new Error(
+                `Cannot fetch comments without all fields (instance=${instance}, user=${user}, post-id=${postId})`,
+              );
+            }
+            const response = await fetch(`https://${instance}/api/v1/statuses/${postId}/context`);
+            if (!response.ok) {
+              throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
+            }
+            const data: { ancestors: Comment[]; descendants: Comment[] } = await response.json();
+            // console.log(data);
+
+            const commentsList: HTMLElement[] = [];
+            const commentMap: Record<string, number> = {};
+            const commentTemplate = document.getElementById("template-comment-box") as HTMLTemplateElement;
+            data.descendants.forEach((comment) => {
+              const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
+
+              const commentBoxAuthor = commentBox.querySelector<HTMLAnchorElement>("[data-author]")!;
+              commentBoxAuthor.href = comment.account.url;
+              commentBoxAuthor.target = "_blank";
+              const avatar = commentBoxAuthor.querySelector<HTMLImageElement>("[data-avatar]")!;
+              avatar.src = comment.account.avatar;
+              avatar.alt = `Profile picture of ${comment.account.username}`;
+              const displayName = commentBoxAuthor.querySelector<HTMLSpanElement>("[data-display-name]")!;
+              displayName.innerHTML = replaceEmojis(
+                comment.account.display_name,
+                comment.account.emojis,
+                "inline mx-[1px] w-5",
+              );
+
+              const commentBoxPostLink = commentBox.querySelector<HTMLAnchorElement>("[data-post-link]")!;
+              commentBoxPostLink.href = comment.url;
+              commentBoxPostLink.target = "_blank";
+              const publishDate = commentBoxPostLink.querySelector<HTMLSpanElement>("[data-publish-date]")!;
+              // TO-DO Pretty format date
+              publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
+                month: "short",
+                day: "numeric",
+                year: "numeric",
+                hour: "2-digit",
+                minute: "2-digit",
+              });
+
+              if (comment.edited_at) {
+                const edited = document.createElement("span");
+                edited.className = "italic";
+                edited.innerText = "(edited)";
+                commentBoxPostLink.appendChild(edited);
+              }
+
+              const commentBoxContent = commentBox.querySelector<HTMLDivElement>("[data-content]")!;
+              commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis, "inline mx-[1px] w-5");
+
+              const commentBoxFavorites = commentBox.querySelector<HTMLSpanElement>("[data-favorites]")!;
+              commentBoxFavorites.innerText = comment.favourites_count.toString();
+
+              const commentBoxReblogs = commentBox.querySelector<HTMLSpanElement>("[data-reblogs]")!;
+              commentBoxReblogs.innerText = comment.reblogs_count.toString();
+
+              if (comment.in_reply_to_id === postId || !(comment.in_reply_to_id in commentMap)) {
+                commentMap[comment.id] = commentsList.length;
+                commentsList.push(commentBox);
+              } else {
+                const commentsIndex = commentMap[comment.in_reply_to_id];
+                commentMap[comment.id] = commentsIndex;
+                commentsList[commentsIndex]
+                  .querySelector<HTMLDivElement>("[data-comment-thread]")!
+                  .appendChild(commentBox);
+              }
+            });
+            const commentsDiv = commentSection.querySelector<HTMLDivElement>("#comments")!;
+            if (commentsList.length === 0) {
+              commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">Be the first to join the conversation on Mastodon</a>.</p>`;
+            } else {
+              commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">by replying on Mastodon</a>.</p>`;
+              commentsDiv.append(...commentsList);
+            }
+          } catch (e) {
+            loadCommentsButton.innerHTML = `<span>Unable to load comments.</span>`;
+            console.error("Fetch Mastodon comments error", e);
+          }
+        };
+        renderComments();
+      });
+    }
+  })();
+</script>
diff --git a/src/content/config.ts b/src/content/config.ts
index 0ac24f4..4829f65 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -20,6 +20,11 @@ const localUrlRegex = /^((\/?\.\.(?=\/))+|(\.(?=\/)))?\/?[a-z0-9_-]+(\/[a-z0-9_-
 
 const lang = z.enum(["eng", "tok"]).default("eng");
 const website = z.enum(WEBSITE_LIST);
+const mastodonPost = z.object({
+  instance: z.string(),
+  user: z.string(),
+  postId: z.string(),
+});
 
 export type Lang = z.output<typeof lang>;
 export type Website = z.infer<typeof website>;
@@ -53,6 +58,7 @@ const storiesCollection = defineCollection({
       next: reference("stories").nullish(),
       relatedStories: z.array(reference("stories")).default([]),
       relatedGames: z.array(reference("games")).default([]),
+      mastodonPost: mastodonPost.optional(),
     }),
 });
 
@@ -78,6 +84,7 @@ const gamesCollection = defineCollection({
       lang,
       relatedStories: z.array(reference("stories")).default([]),
       relatedGames: z.array(reference("games")).default([]),
+      mastodonPost: mastodonPost.optional(),
     }),
 });
 
diff --git a/src/content/games/crossing-over.md b/src/content/games/crossing-over.md
index 2df033d..2d169b3 100644
--- a/src/content/games/crossing-over.md
+++ b/src/content/games/crossing-over.md
@@ -26,6 +26,10 @@ descriptionPlaintext: >
   An original soundtrack with 9 exclusive songs;
   A challenging physics-based fishing minigame with scaling difficulty;
   And a special cutscene...
+mastodonPost:
+  instance: meow.social
+  user: BadManners
+  postId: "112009918919441027"
 tags:
   - oral vore
   - anthro predator
diff --git a/src/content/stories/tiny-accident.md b/src/content/stories/tiny-accident.md
index 6333b8c..403f94f 100644
--- a/src/content/stories/tiny-accident.md
+++ b/src/content/stories/tiny-accident.md
@@ -14,6 +14,10 @@ descriptionPlaintext: >
   Kolo's day at the airship is nearly over, but a tiny stalker will unwittingly make his evening quite eventful...
 
   Finally got around to finishing a story ever since I worked on Crossing Over! I wanna get back into writing more stuff again, and this short story has finally broken my writer's block. My goal is to go back to working on commissions, but I feel I'm not quite in the headspace to tackle them just yet... Nevertheless, I hope you enjoy this!
+mastodonPost:
+  instance: meow.social
+  user: BadManners
+  postId: "112157812554023271"
 tags:
   - anthro predator
   - anthro prey
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 0684bff..7dd5d41 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -3,6 +3,11 @@ import "../styles/base.css";
 import "../styles/fonts.css";
 import DarkModeScript from "../components/DarkModeScript.astro";
 import AgeRestrictedModal from "../components/AgeRestrictedModal.astro";
+
+type Props = {
+  pageTitle?: string;
+};
+
 const { pageTitle } = Astro.props;
 ---
 
diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro
index 842870c..96b5b2e 100644
--- a/src/layouts/GameLayout.astro
+++ b/src/layouts/GameLayout.astro
@@ -2,13 +2,12 @@
 import { getImage } from "astro:assets";
 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";
 import { slug } from "github-slugger";
 import BaseLayout from "./BaseLayout.astro";
 import Authors from "../components/Authors.astro";
 import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
 import Prose from "../components/Prose.astro";
+import MastodonComments from "../components/MastodonComments.astro";
 
 type Props = CollectionEntry<"games">["data"];
 
@@ -118,7 +117,7 @@ const thumbnail =
         ) : null
       }
       <hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
-      <article id="story" class="pr-1 font-serif">
+      <article id="game" class="pr-1 font-serif">
         <Prose>
           <slot />
         </Prose>
@@ -137,9 +136,15 @@ const thumbnail =
             id="publish-date"
             class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
             aria-label="Publish date"
-            aria-description={formatDate(props.pubDate, "MMMM do, yyyy", { locale: enUSLocale })}
+            aria-description={props.pubDate.toLocaleDateString("en-US", {
+              month: "long",
+              day: "numeric",
+              year: "numeric",
+            })}
           >
-            {formatDate(props.pubDate, "yyyy-MM-dd")}
+            {props.lang === "tok"
+              ? `tenpo suno ${props.pubDate.toISOString().slice(undefined, 10)}`
+              : props.pubDate.toISOString().slice(undefined, 10)}
           </p>
         )
       }
@@ -175,10 +180,21 @@ const thumbnail =
           }
         </ul>
       </section>
+      <MastodonComments
+        instance={props.mastodonPost?.instance}
+        user={props.mastodonPost?.user}
+        postId={props.mastodonPost?.postId}
+      />
     </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>
+      <span
+        >&copy; {
+          props.lang === "tok" ? `tenpo pi sike suno ${props.pubDate.getFullYear()}` : props.pubDate.getFullYear()
+        } |
+      </span>
+      <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
+        >{props.lang === "eng" ? "Licenses" : props.lang === "tok" ? "lipu lawa" : null}</a
+      >
     </div>
   </div>
 </BaseLayout>
diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro
index 9d28139..a3067c7 100644
--- a/src/layouts/StoryLayout.astro
+++ b/src/layouts/StoryLayout.astro
@@ -2,14 +2,13 @@
 import { getImage } from "astro:assets";
 import { type CollectionEntry, getEntry, getEntries, getCollection } 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";
 import { slug } from "github-slugger";
 import BaseLayout from "./BaseLayout.astro";
 import Authors from "../components/Authors.astro";
 import UserComponent from "../components/UserComponent.astro";
 import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
 import Prose from "../components/Prose.astro";
+import MastodonComments from "../components/MastodonComments.astro";
 
 type Props = CollectionEntry<"stories">["data"];
 
@@ -226,11 +225,15 @@ const thumbnail =
             id="publish-date"
             class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
             aria-label="Publish date"
-            aria-description={formatDate(props.pubDate, "MMMM do, yyyy", { locale: enUSLocale })}
+            aria-description={props.pubDate.toLocaleDateString("en-US", {
+              month: "long",
+              day: "numeric",
+              year: "numeric",
+            })}
           >
             {props.lang === "tok"
-              ? `tenpo suno ${formatDate(props.pubDate, "yyyy-MM-dd")}`
-              : formatDate(props.pubDate, "yyyy-MM-dd")}
+              ? `tenpo suno ${props.pubDate.toISOString().slice(undefined, 10)}`
+              : props.pubDate.toISOString().slice(undefined, 10)}
           </p>
         )
       }
@@ -322,13 +325,16 @@ const thumbnail =
           }
         </ul>
       </section>
+      <MastodonComments
+        instance={props.mastodonPost?.instance}
+        user={props.mastodonPost?.user}
+        postId={props.mastodonPost?.postId}
+      />
     </main>
     <div class="pt-6 text-center text-xs text-black dark:text-white">
       <span
         >&copy; {
-          props.lang === "tok"
-            ? `tenpo pi sike suno ${formatDate(props.pubDate, "yyyy")}`
-            : formatDate(props.pubDate, "yyyy")
+          props.lang === "tok" ? `tenpo pi sike suno ${props.pubDate.getFullYear()}` : props.pubDate.getFullYear()
         } |
       </span>
       <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index e0df54a..8956900 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -1,7 +1,6 @@
 import rss, { type RSSFeedItem } from "@astrojs/rss";
 import type { APIRoute } from "astro";
 import { getCollection } from "astro:content";
-import { getUnixTime, addMinutes } from "date-fns";
 
 type FeedItem = RSSFeedItem & {
   pubDate: Date;
@@ -9,9 +8,19 @@ type FeedItem = RSSFeedItem & {
 
 const MAX_ITEMS = 10;
 
+function toNoonUTCDate(date: Date) {
+  const adjustedDate = new Date(date);
+  adjustedDate.setUTCHours(12);
+  return adjustedDate;
+}
+
 export const GET: APIRoute = async ({ site }) => {
-  const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)).slice(0, MAX_ITEMS);
-  const games = (await getCollection("games", (game) => !game.data.isDraft)).sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)).slice(0, MAX_ITEMS);
+  const stories = (await getCollection("stories", (story) => !story.data.isDraft))
+    .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+    .slice(0, MAX_ITEMS);
+  const games = (await getCollection("games", (game) => !game.data.isDraft))
+    .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+    .slice(0, MAX_ITEMS);
   return rss({
     title: "Gallery | Bad Manners",
     description: "Stories, games, and (possibly) more by Bad Manners",
@@ -19,7 +28,7 @@ export const GET: APIRoute = async ({ site }) => {
     items: [
       stories.map<FeedItem>((story) => ({
         title: `New story! "${story.data.title}"`,
-        pubDate: addMinutes(story.data.pubDate, 12 * 60 - story.data.pubDate.getTimezoneOffset()),
+        pubDate: toNoonUTCDate(story.data.pubDate),
         link: `/stories/${story.slug}`,
         description:
           `Word count: ${story.data.wordCount}. ${story.data.contentWarning} ${story.data.descriptionPlaintext || story.data.description}`
@@ -29,7 +38,7 @@ export const GET: APIRoute = async ({ site }) => {
       })),
       games.map<FeedItem>((game) => ({
         title: `New game! "${game.data.title}"`,
-        pubDate: addMinutes(game.data.pubDate, 12 * 60 - game.data.pubDate.getTimezoneOffset()),
+        pubDate: toNoonUTCDate(game.data.pubDate),
         link: `/games/${game.slug}`,
         description: `${game.data.contentWarning} ${game.data.descriptionPlaintext || game.data.description}`
           .replaceAll(/[\n ]+/g, " ")
@@ -38,7 +47,7 @@ export const GET: APIRoute = async ({ site }) => {
       })),
     ]
       .flat()
-      .sort((a, b) => getUnixTime(b.pubDate) - getUnixTime(a.pubDate))
+      .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
       .slice(0, MAX_ITEMS),
   });
 };
diff --git a/src/pages/games.astro b/src/pages/games.astro
index 9e13955..faf0e5e 100644
--- a/src/pages/games.astro
+++ b/src/pages/games.astro
@@ -1,12 +1,10 @@
 ---
 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";
 
 const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
-  (a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate),
+  (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
 );
 ---
 
@@ -14,26 +12,23 @@ const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
   <meta slot="head-description" content="Bad Manners || A game that I've gone and done." property="og:description" />
   <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Games</h1>
   <p class="my-4">A game that I've gone and done.</p>
-  <ul class="my-6 flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start">
+  <ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
       games.map((game) => (
         <li>
           <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
             {game.data.thumbnail ? (
-              <div class="max-w-[288px] aspect-[630/500] flex justify-center">
-                <Image
-                  class="m-auto"
-                  src={game.data.thumbnail}
-                  alt={`Thumbnail for ${game.data.title}`}
-                  width={288}
-                />
+              <div class="flex aspect-[630/500] max-w-[288px] justify-center">
+                <Image class="m-auto" src={game.data.thumbnail} alt={`Thumbnail for ${game.data.title}`} width={288} />
               </div>
             ) : null}
             <div class="max-w-[288px] text-sm">
               <>
                 <span>{game.data.title}</span>
                 <br />
-                <span class="italic">{formatDate(game.data.pubDate, "MMM d, yyyy", { locale: enUSLocale })}</span>
+                <span class="italic">
+                  {game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+                </span>
               </>
             </div>
           </a>
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 8012ce0..192b12c 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,8 +1,6 @@
 ---
 import { type CollectionEntry, getCollection } from "astro:content";
 import { Image } from "astro:assets";
-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 MAX_ITEMS = 5;
@@ -15,25 +13,32 @@ interface LatestItemsEntry {
   pubDate: Date;
 }
 
-const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)).slice(0, MAX_ITEMS);
-const games = (await getCollection("games", (game) => !game.data.isDraft)).sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)).slice(0, MAX_ITEMS);
+const stories = (await getCollection("stories", (story) => !story.data.isDraft))
+  .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+  .slice(0, MAX_ITEMS);
+const games = (await getCollection("games", (game) => !game.data.isDraft))
+  .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+  .slice(0, MAX_ITEMS);
 
 const latestItems: LatestItemsEntry[] = [
-  stories.map<LatestItemsEntry>(story => ({
+  stories.map<LatestItemsEntry>((story) => ({
     type: "Story",
     thumbnail: story.data.thumbnail,
     href: `/stories/${story.slug}`,
     title: story.data.title,
     pubDate: story.data.pubDate,
   })),
-  games.map<LatestItemsEntry>(game => ({
+  games.map<LatestItemsEntry>((game) => ({
     type: "Game",
     thumbnail: game.data.thumbnail,
     href: `/games/${game.slug}`,
     title: game.data.title,
     pubDate: game.data.pubDate,
   })),
-].flat().sort((a, b) => getUnixTime(b.pubDate) - getUnixTime(a.pubDate)).slice(0, MAX_ITEMS);
+]
+  .flat()
+  .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
+  .slice(0, MAX_ITEMS);
 ---
 
 <GalleryLayout pageTitle="Gallery">
@@ -54,25 +59,23 @@ const latestItems: LatestItemsEntry[] = [
   </p>
   <section class="my-2" aria-labelledby="latest-uploads">
     <h2 id="latest-uploads" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">Latest uploads</h2>
-    <ul class="flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start">
+    <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
       {
         latestItems.map((entry) => (
           <li class="break-inside-avoid">
             <a class="text-link hover:underline focus:underline" href={entry.href}>
               {entry.thumbnail ? (
-                <div class="max-w-[192px] aspect-square flex justify-center">
-                  <Image
-                    class="m-auto"
-                    src={entry.thumbnail}
-                    alt={`Thumbnail for ${entry.title}`}
-                    width={192}
-                  />
+                <div class="flex aspect-square max-w-[192px] justify-center">
+                  <Image class="m-auto" src={entry.thumbnail} alt={`Thumbnail for ${entry.title}`} width={192} />
                 </div>
               ) : null}
               <div class="max-w-[192px] text-sm">
                 <span>{entry.title}</span>
                 <br />
-                <span class="italic">{entry.type} &ndash; {formatDate(entry.pubDate, "MMM d, yyyy", { locale: enUSLocale })}</span>
+                <span class="italic">
+                  {entry.type} &ndash;{" "}
+                  {entry.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+                </span>
               </div>
             </a>
           </li>
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index cee229e..4cebc20 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -2,14 +2,12 @@
 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 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),
+    (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
   );
   return paginate(stories, { pageSize: 30 });
 };
@@ -62,13 +60,13 @@ const totalPages = Math.ceil(page.total / page.size);
       )
     }
   </div>
-  <ul class="flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start">
+  <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
       page.data.map((story) => (
         <li class="break-inside-avoid">
           <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
             {story.data.thumbnail ? (
-              <div class="max-w-[192px] aspect-square flex justify-center">
+              <div class="flex aspect-square max-w-[192px] justify-center">
                 <Image
                   class="m-auto"
                   src={story.data.thumbnail}
@@ -80,7 +78,9 @@ const totalPages = Math.ceil(page.total / page.size);
             <div class="max-w-[192px] text-sm">
               <span>{story.data.title}</span>
               <br />
-              <span class="italic">{formatDate(story.data.pubDate, "MMM d, yyyy", { locale: enUSLocale })}</span>
+              <span class="italic">
+                {story.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+              </span>
             </div>
           </a>
         </li>
diff --git a/src/pages/stories/export/description/[website]/[...slug].ts b/src/pages/stories/export/description/[website]/[...slug].ts
index fd9c52f..f60d5a7 100644
--- a/src/pages/stories/export/description/[website]/[...slug].ts
+++ b/src/pages/stories/export/description/[website]/[...slug].ts
@@ -1,7 +1,7 @@
 import { type APIRoute, type GetStaticPaths } from "astro";
 import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
 import { marked, type RendererApi } from "marked";
-import he from "he";
+import { decode as tinyDecode } from "tiny-decode";
 import { type Website } from "../../../../../content/config";
 
 const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[];
@@ -269,7 +269,7 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: {
   const markdownExport: ReadonlyArray<ExportWebsite> = ["weasyl"] as const;
   // BBCode exports
   if (bbcodeExports.includes(website)) {
-    storyDescription = he.decode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription));
+    storyDescription = tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription));
     headers["Content-Type"] = "text/plain; charset=utf-8";
     // Markdown exports (no-op)
   } else if (!markdownExport.includes(website)) {
diff --git a/src/pages/stories/the-lost-of-the-marshes.astro b/src/pages/stories/the-lost-of-the-marshes.astro
index c60c34b..174b037 100644
--- a/src/pages/stories/the-lost-of-the-marshes.astro
+++ b/src/pages/stories/the-lost-of-the-marshes.astro
@@ -1,7 +1,6 @@
 ---
 import { getCollection, getEntry } from "astro:content";
 import { Image } from "astro:assets";
-import { getUnixTime } from "date-fns";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
 import mapImage from "../../assets/images/tlotm_map.jpg";
 
@@ -9,10 +8,10 @@ const series = await getEntry("series", "the-lost-of-the-marshes");
 const stories = await getCollection("stories", (story) => !story.data.isDraft && story.data.series?.id === series.id);
 const mainChapters = stories
   .filter((story) => story.slug.startsWith("the-lost-of-the-marshes/chapter-"))
-  .sort((a, b) => getUnixTime(a.data.pubDate) - getUnixTime(b.data.pubDate));
+  .sort((a, b) => a.data.pubDate.getTime() - b.data.pubDate.getTime());
 const bonusChapters = stories
   .filter((story) => story.slug.startsWith("the-lost-of-the-marshes/bonus-"))
-  .sort((a, b) => getUnixTime(a.data.pubDate) - getUnixTime(b.data.pubDate));
+  .sort((a, b) => a.data.pubDate.getTime() - b.data.pubDate.getTime());
 const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summary);
 ---
 
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index 950fe0e..e89e8af 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -3,7 +3,6 @@ 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 const getStaticPaths: GetStaticPaths = async () => {
@@ -27,10 +26,10 @@ export const getStaticPaths: GetStaticPaths = async () => {
         tag,
         stories: stories
           .filter((story) => !story.data.isDraft && story.data.tags.includes(tag))
-          .sort((a, b) => getUnixTime(b.data.pubDate!) - getUnixTime(a.data.pubDate!)),
+          .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()),
         games: games
           .filter((game) => !game.data.isDraft && game.data.tags.includes(tag))
-          .sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)),
+          .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()),
       },
     }));
 };
@@ -69,12 +68,12 @@ if (count == 1) {
         <h2 id="content-stories" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
           Stories
         </h2>
-        <ul class="flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start">
+        <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
           {stories.map((story) => (
             <li class="break-inside-avoid">
               <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
                 {story.data.thumbnail ? (
-                  <div class="max-w-[192px] aspect-square flex justify-center">
+                  <div class="flex aspect-square max-w-[192px] justify-center">
                     <Image
                       class="m-auto"
                       src={story.data.thumbnail}
@@ -97,19 +96,19 @@ if (count == 1) {
         <h2 id="content-games" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
           Games
         </h2>
-        <ul class="flex flex-wrap justify-center gap-4 text-center md:justify-normal items-start">
+        <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
           {games.map((game) => (
             <li class="break-inside-avoid">
               <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
                 {game.data.thumbnail ? (
-                  <div class="max-w-[192px] aspect-[630/500] flex justify-center">
-                  <Image
-                    class="m-auto"
-                    src={game.data.thumbnail}
-                    alt={`Thumbnail for ${game.data.title}`}
-                    width={192}
-                  />
-                </div>
+                  <div class="flex aspect-[630/500] max-w-[192px] justify-center">
+                    <Image
+                      class="m-auto"
+                      src={game.data.thumbnail}
+                      alt={`Thumbnail for ${game.data.title}`}
+                      width={192}
+                    />
+                  </div>
                 ) : null}
                 <div class="max-w-48 text-sm">{game.data.title}</div>
               </a>