From cd67f6a5c5ec89d983c0f835f3e6bed54f939498 Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Mon, 12 Aug 2024 21:38:16 -0300
Subject: [PATCH] Add qualifyLocalURLsInMarkdown()

This will handle special links in the description and similar fields
---
 package-lock.json                             | 502 +++++++++++++++++-
 package.json                                  |  12 +-
 src/components/CopyrightedCharacters.astro    |  10 +-
 src/content/config.ts                         |   5 +-
 .../series/the-lost-of-the-marshes.yaml       |   2 +-
 src/content/stories/rose-s-binge.md           |   1 +
 src/content/tag-categories/8-other-kinks.yaml |   2 +
 src/i18n/index.ts                             |  11 +-
 src/layouts/PublishedContentLayout.astro      |  24 +-
 src/pages/api/export-story/[...slug].ts       | 225 +++-----
 src/pages/feed.xml.ts                         | 158 +++---
 src/pages/stories/[...slug].astro             |   3 +-
 src/pages/stories/[page].astro                |   7 +-
 .../stories/the-lost-of-the-marshes.astro     |   2 +-
 src/pages/tags.astro                          |  14 +-
 src/pages/tags/[slug].astro                   |  63 ++-
 src/utils/format_copyrighted_characters.ts    |  11 +-
 src/utils/get_username_for_lang.ts            |   2 +-
 src/utils/get_website_link_for_user.ts        | 114 ++++
 src/utils/qualify_local_urls_in_markdown.ts   | 110 ++++
 20 files changed, 982 insertions(+), 296 deletions(-)
 create mode 100644 src/utils/get_website_link_for_user.ts
 create mode 100644 src/utils/qualify_local_urls_in_markdown.ts

diff --git a/package-lock.json b/package-lock.json
index 8dafd67..7c8dcc0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,27 +1,27 @@
 {
   "name": "gallery-badmanners-xyz",
-  "version": "1.7.0",
+  "version": "1.7.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery-badmanners-xyz",
-      "version": "1.7.0",
+      "version": "1.7.1",
       "hasInstallScript": true,
       "dependencies": {
         "@astrojs/check": "^0.9.2",
         "@astrojs/rss": "^4.0.7",
         "@astrojs/tailwind": "^5.1.0",
         "@astropub/md": "^1.0.0",
-        "@tailwindcss/typography": "^0.5.13",
-        "astro": "^4.13.1",
+        "@tailwindcss/typography": "^0.5.14",
+        "astro": "^4.13.3",
         "astro-pagefind": "^1.6.0",
         "github-slugger": "^2.0.0",
         "marked": "^12.0.2",
         "pagefind": "^1.1.0",
         "reading-time": "^1.5.0",
         "sanitize-html": "^2.13.0",
-        "tailwindcss": "^3.4.7",
+        "tailwindcss": "^3.4.9",
         "tiny-decode": "^0.1.3",
         "typescript": "^5.5.4"
       },
@@ -32,8 +32,8 @@
         "fetch-retry": "^6.0.0",
         "prettier": "^3.3.3",
         "prettier-plugin-astro": "^0.14.1",
-        "prettier-plugin-tailwindcss": "^0.6.5",
-        "tsx": "^4.16.5"
+        "prettier-plugin-tailwindcss": "^0.6.6",
+        "tsx": "^4.17.0"
       }
     },
     "../astro-pagefind/packages/astro-pagefind": {
@@ -936,6 +936,23 @@
         "node": ">=12"
       }
     },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz",
+      "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/@esbuild/openbsd-x64": {
       "version": "0.21.5",
       "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
@@ -1824,9 +1841,9 @@
       }
     },
     "node_modules/@tailwindcss/typography": {
-      "version": "0.5.13",
-      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz",
-      "integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==",
+      "version": "0.5.14",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.14.tgz",
+      "integrity": "sha512-ZvOCjUbsJBjL9CxQBn+VEnFpouzuKhxh2dH8xMIWHILL+HfOYtlAkWcyoon8LlzE53d2Yo6YO6pahKKNW3q1YQ==",
       "license": "MIT",
       "dependencies": {
         "lodash.castarray": "^4.4.0",
@@ -2237,12 +2254,12 @@
       }
     },
     "node_modules/astro": {
-      "version": "4.13.1",
-      "resolved": "https://registry.npmjs.org/astro/-/astro-4.13.1.tgz",
-      "integrity": "sha512-VnMjAc+ykFsIVjgbu9Mt/EA1fMIcPMXbU89h3ATwGOzSIKDsQH72bDgfJkWiwk6u0OE90GeP5EPhAT28Twf9oA==",
+      "version": "4.13.3",
+      "resolved": "https://registry.npmjs.org/astro/-/astro-4.13.3.tgz",
+      "integrity": "sha512-MyhmM0v5sphiVwxAm5jjKxWeuPZijWPJ8Ajdign9QzEmLWSH8vUYIJWx/dWRQ6vF1I0jXrksoj3wtw5nzXt9nw==",
       "license": "MIT",
       "dependencies": {
-        "@astrojs/compiler": "^2.10.0",
+        "@astrojs/compiler": "^2.10.1",
         "@astrojs/internal-helpers": "0.4.1",
         "@astrojs/markdown-remark": "5.2.0",
         "@astrojs/telemetry": "3.1.0",
@@ -2291,7 +2308,7 @@
         "prompts": "^2.4.2",
         "rehype": "^13.0.1",
         "semver": "^7.6.3",
-        "shiki": "^1.12.0",
+        "shiki": "^1.12.1",
         "string-width": "^7.2.0",
         "strip-ansi": "^7.1.0",
         "tsconfck": "^3.1.1",
@@ -5658,9 +5675,9 @@
       }
     },
     "node_modules/prettier-plugin-tailwindcss": {
-      "version": "0.6.5",
-      "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.5.tgz",
-      "integrity": "sha512-axfeOArc/RiGHjOIy9HytehlC0ZLeMaqY09mm8YCkMzznKiDkwFzOpBvtuhuv3xG5qB73+Mj7OCe2j/L1ryfuQ==",
+      "version": "0.6.6",
+      "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.6.tgz",
+      "integrity": "sha512-OPva5S7WAsPLEsOuOWXATi13QrCKACCiIonFgIR6V4lYv4QLp++UXVhZSzRbZxXGimkQtQT86CC6fQqTOybGng==",
       "dev": true,
       "license": "MIT",
       "engines": {
@@ -5678,6 +5695,7 @@
         "prettier-plugin-import-sort": "*",
         "prettier-plugin-jsdoc": "*",
         "prettier-plugin-marko": "*",
+        "prettier-plugin-multiline-arrays": "*",
         "prettier-plugin-organize-attributes": "*",
         "prettier-plugin-organize-imports": "*",
         "prettier-plugin-sort-imports": "*",
@@ -5715,6 +5733,9 @@
         "prettier-plugin-marko": {
           "optional": true
         },
+        "prettier-plugin-multiline-arrays": {
+          "optional": true
+        },
         "prettier-plugin-organize-attributes": {
           "optional": true
         },
@@ -6568,9 +6589,9 @@
       }
     },
     "node_modules/tailwindcss": {
-      "version": "3.4.7",
-      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz",
-      "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==",
+      "version": "3.4.9",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz",
+      "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==",
       "license": "MIT",
       "dependencies": {
         "@alloc/quick-lru": "^5.2.0",
@@ -6790,13 +6811,13 @@
       "optional": true
     },
     "node_modules/tsx": {
-      "version": "4.16.5",
-      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.5.tgz",
-      "integrity": "sha512-ArsiAQHEW2iGaqZ8fTA1nX0a+lN5mNTyuGRRO6OW3H/Yno1y9/t1f9YOI1Cfoqz63VAthn++ZYcbDP7jPflc+A==",
+      "version": "4.17.0",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.17.0.tgz",
+      "integrity": "sha512-eN4mnDA5UMKDt4YZixo9tBioibaMBpoxBkD+rIPAjVmYERSG0/dWEY1CEFuV89CgASlKL499q8AhmkMnnjtOJg==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
-        "esbuild": "~0.21.5",
+        "esbuild": "~0.23.0",
         "get-tsconfig": "^4.7.5"
       },
       "bin": {
@@ -6809,6 +6830,437 @@
         "fsevents": "~2.3.3"
       }
     },
+    "node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz",
+      "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/android-arm": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz",
+      "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/android-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz",
+      "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/android-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz",
+      "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz",
+      "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/darwin-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz",
+      "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz",
+      "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz",
+      "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-arm": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz",
+      "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz",
+      "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-ia32": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz",
+      "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-loong64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz",
+      "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz",
+      "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz",
+      "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz",
+      "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-s390x": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz",
+      "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/linux-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz",
+      "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz",
+      "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz",
+      "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/sunos-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz",
+      "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/win32-arm64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz",
+      "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/win32-ia32": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz",
+      "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/@esbuild/win32-x64": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz",
+      "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tsx/node_modules/esbuild": {
+      "version": "0.23.0",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz",
+      "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.23.0",
+        "@esbuild/android-arm": "0.23.0",
+        "@esbuild/android-arm64": "0.23.0",
+        "@esbuild/android-x64": "0.23.0",
+        "@esbuild/darwin-arm64": "0.23.0",
+        "@esbuild/darwin-x64": "0.23.0",
+        "@esbuild/freebsd-arm64": "0.23.0",
+        "@esbuild/freebsd-x64": "0.23.0",
+        "@esbuild/linux-arm": "0.23.0",
+        "@esbuild/linux-arm64": "0.23.0",
+        "@esbuild/linux-ia32": "0.23.0",
+        "@esbuild/linux-loong64": "0.23.0",
+        "@esbuild/linux-mips64el": "0.23.0",
+        "@esbuild/linux-ppc64": "0.23.0",
+        "@esbuild/linux-riscv64": "0.23.0",
+        "@esbuild/linux-s390x": "0.23.0",
+        "@esbuild/linux-x64": "0.23.0",
+        "@esbuild/netbsd-x64": "0.23.0",
+        "@esbuild/openbsd-arm64": "0.23.0",
+        "@esbuild/openbsd-x64": "0.23.0",
+        "@esbuild/sunos-x64": "0.23.0",
+        "@esbuild/win32-arm64": "0.23.0",
+        "@esbuild/win32-ia32": "0.23.0",
+        "@esbuild/win32-x64": "0.23.0"
+      }
+    },
     "node_modules/type-fest": {
       "version": "2.19.0",
       "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
diff --git a/package.json b/package.json
index 4519d7e..9d7eb88 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery-badmanners-xyz",
   "type": "module",
-  "version": "1.7.0",
+  "version": "1.7.1",
   "scripts": {
     "postinstall": "astro sync",
     "dev": "astro dev",
@@ -19,15 +19,15 @@
     "@astrojs/rss": "^4.0.7",
     "@astrojs/tailwind": "^5.1.0",
     "@astropub/md": "^1.0.0",
-    "@tailwindcss/typography": "^0.5.13",
-    "astro": "^4.13.1",
+    "@tailwindcss/typography": "^0.5.14",
+    "astro": "^4.13.3",
     "astro-pagefind": "^1.6.0",
     "github-slugger": "^2.0.0",
     "marked": "^12.0.2",
     "pagefind": "^1.1.0",
     "reading-time": "^1.5.0",
     "sanitize-html": "^2.13.0",
-    "tailwindcss": "^3.4.7",
+    "tailwindcss": "^3.4.9",
     "tiny-decode": "^0.1.3",
     "typescript": "^5.5.4"
   },
@@ -38,7 +38,7 @@
     "fetch-retry": "^6.0.0",
     "prettier": "^3.3.3",
     "prettier-plugin-astro": "^0.14.1",
-    "prettier-plugin-tailwindcss": "^0.6.5",
-    "tsx": "^4.16.5"
+    "prettier-plugin-tailwindcss": "^0.6.6",
+    "tsx": "^4.17.0"
   }
 }
diff --git a/src/components/CopyrightedCharacters.astro b/src/components/CopyrightedCharacters.astro
index 6de2ad6..7d63011 100644
--- a/src/components/CopyrightedCharacters.astro
+++ b/src/components/CopyrightedCharacters.astro
@@ -18,15 +18,13 @@ const charactersPerUser = copyrightedCharacters ? await formatCopyrightedCharact
   charactersPerUser ? (
     <section id="copyrighted-characters" aria-label={t(lang, "characters/copyrighted_characters_aria_label")}>
       <ul>
-        {charactersPerUser.map(([owner, characterList]) => (
+        {charactersPerUser.map(({ user, characters }) => (
           <CopyrightedCharactersItem
-            stringFunction={
-              characterList[0] === ""
-                ? (user) => t(lang, "characters/all_characters_are_copyrighted_by", user)
-                : (user) => t(lang, "characters/characters_are_copyrighted_by", user, characterList)
+            stringFunction={(user) =>
+              t(lang, "characters/characters_are_copyrighted_by", user, characters[0] === "" ? [] : characters)
             }
           >
-            <UserComponent lang={lang} user={owner} />
+            <UserComponent lang={lang} user={user} />
           </CopyrightedCharactersItem>
         ))}
       </ul>
diff --git a/src/content/config.ts b/src/content/config.ts
index 70089cf..8815165 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -217,10 +217,11 @@ const publishedContent = z.object({
 // Types
 
 export type Lang = z.output<typeof lang>;
-export type Website = keyof z.input<typeof websiteLinks>;
+export type UserWebsite = keyof z.input<typeof websiteLinks>;
 export type GamePlatform = z.infer<typeof platform>;
 export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
 export type PublishedContent = z.infer<typeof publishedContent>;
+export type PostWebsite = keyof PublishedContent["posts"];
 
 // Content collections
 
@@ -305,7 +306,7 @@ const seriesCollection = defineCollection({
   schema: z.object({
     // Required parameters
     name: z.string(),
-    url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`),
+    link: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"link" must be a local URL`),
   }),
 });
 
diff --git a/src/content/series/the-lost-of-the-marshes.yaml b/src/content/series/the-lost-of-the-marshes.yaml
index dbdb08e..570ebbf 100644
--- a/src/content/series/the-lost-of-the-marshes.yaml
+++ b/src/content/series/the-lost-of-the-marshes.yaml
@@ -1,2 +1,2 @@
 name: The Lost of the Marshes
-url: /stories/the-lost-of-the-marshes
+link: /stories/the-lost-of-the-marshes
diff --git a/src/content/stories/rose-s-binge.md b/src/content/stories/rose-s-binge.md
index 689649d..2966d9a 100644
--- a/src/content/stories/rose-s-binge.md
+++ b/src/content/stories/rose-s-binge.md
@@ -35,6 +35,7 @@ tags:
   - nudity
   - masturbation
   - plushie
+  - wardrobe malfunction
   - commission
 commissioner: dee-lumeni
 copyrightedCharacters:
diff --git a/src/content/tag-categories/8-other-kinks.yaml b/src/content/tag-categories/8-other-kinks.yaml
index ad1f679..0da71f6 100644
--- a/src/content/tag-categories/8-other-kinks.yaml
+++ b/src/content/tag-categories/8-other-kinks.yaml
@@ -23,3 +23,5 @@ tags:
     description: Scenarios where sexual consent is dubious.
   - name: plushie
     description: Scenarios with sexual action involving stuffed toys or plushie-like characters.
+  - name: wardrobe malfunction
+    description: Scenarios where a mishap happens with someone's clothes, such as accidentally revealing body parts and/or bursting open.
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index ef21b7a..ec36876 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -238,12 +238,11 @@ const UI_STRINGS = {
   },
   "characters/characters_are_copyrighted_by": {
     en: (owner: string, charactersList: string[]) =>
-      charactersList.length == 1
-        ? `${charactersList[0]} is © ${owner}`
-        : `${UI_STRINGS["util/join_names"].en(charactersList)} are © ${owner}`,
-  },
-  "characters/all_characters_are_copyrighted_by": {
-    en: (owner: string) => `All characters are © ${owner}`,
+      charactersList.length == 0
+        ? `All characters are © ${owner}`
+        : charactersList.length == 1
+          ? `${charactersList[0]} is © ${owner}`
+          : `${UI_STRINGS["util/join_names"].en(charactersList)} are © ${owner}`,
   },
   // Tag-related strings
   "tag/total_works_with_tag": {
diff --git a/src/layouts/PublishedContentLayout.astro b/src/layouts/PublishedContentLayout.astro
index 2571500..96185fd 100644
--- a/src/layouts/PublishedContentLayout.astro
+++ b/src/layouts/PublishedContentLayout.astro
@@ -10,6 +10,7 @@ import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
 import Prose from "../components/Prose.astro";
 import MastodonComments from "../components/MastodonComments.astro";
 import type { CopyrightedCharacters as CopyrightedCharactersType } from "../content/config";
+import { qualifyLocalURLsInMarkdown } from "../utils/qualify_local_urls_in_markdown";
 
 interface RelatedContent {
   link: string;
@@ -62,14 +63,16 @@ const categorizedTags = Object.fromEntries(
     ),
   ),
 );
+const description = await qualifyLocalURLsInMarkdown(props.description, props.lang);
+const summary = props.summary && (await qualifyLocalURLsInMarkdown(props.summary, props.lang));
 const tags = props.tags.map<{ id: string; name: string }>((tag) => {
   const tagSlug = slug(tag);
   if (!(tag in categorizedTags)) {
-    console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
+    console.warn(`WARNING: Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
     return { id: tagSlug, name: tag };
   }
   if (categorizedTags[tag] == null) {
-    console.warn(`No "${props.lang}" translation for tag "${tag}"`);
+    console.warn(`WARNING: No "${props.lang}" translation for tag "${tag}"`);
     return { id: tagSlug, name: tag };
   }
   return { id: tagSlug, name: categorizedTags[tag] };
@@ -111,7 +114,7 @@ const thumbnail =
         class="pointer-events-auto sticky top-6 flex rounded-full bg-white px-1 py-1 shadow-md dark:bg-black print:hidden"
       >
         <a
-          href={series ? series.data.url : props.labelReturnTo.link}
+          href={series ? series.data.link : props.labelReturnTo.link}
           class="text-link my-1 p-2"
           aria-labelled-by="label-return-to"
         >
@@ -291,12 +294,12 @@ const thumbnail =
           {t(props.lang, "published_content/description")}
         </h2>
         <Prose>
-          <Markdown of={props.description} />
+          <Markdown of={description} />
           <CopyrightedCharacters copyrightedCharacters={props.copyrightedCharacters} lang={props.lang} />
         </Prose>
       </section>
       {
-        props.summary ? (
+        summary ? (
           <section id="summary" class="px-2 font-serif" aria-describedby="title-summary">
             <h2 id="title-summary" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
               {t(props.lang, "published_content/summary")}
@@ -307,7 +310,7 @@ const thumbnail =
               </summary>
               <div class="px-2 py-1">
                 <Prose>
-                  <Markdown of={props.summary} />
+                  <Markdown of={summary} />
                 </Prose>
               </div>
             </details>
@@ -417,10 +420,13 @@ const thumbnail =
             <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
               {t(props.lang, "published_content/tags")}
             </h2>
-            <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
+            <ul class="flex flex-wrap gap-x-2 gap-y-3 px-3">
               {tags.map(({ id, name }) => (
-                <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
-                  <a class="hover:underline focus:underline" href={`/tags/${id}`}>
+                <li>
+                  <a
+                    class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm hover:underline focus:underline dark:bg-bm-600 dark:text-white print:bg-none"
+                    href={`/tags/${id}`}
+                  >
                     {name}
                   </a>
                 </li>
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index ac57980..0a10199 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -1,14 +1,16 @@
 import type { APIRoute, GetStaticPaths } from "astro";
 import { getCollection, type CollectionEntry, getEntries } from "astro:content";
-import type { Website } from "../../../content/config";
-import { t, type Lang } from "../../../i18n";
+import type { PostWebsite } from "../../../content/config";
+import { t } from "../../../i18n";
 import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
 import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
 import { getUsernameForLang } from "../../../utils/get_username_for_lang";
 import { isAnonymousUser } from "../../../utils/is_anonymous_user";
+import { qualifyLocalURLsInMarkdown } from "../../../utils/qualify_local_urls_in_markdown";
+import { getWebsiteLinkForUser } from "../../../utils/get_website_link_for_user";
 
 interface ExportWebsiteInfo {
-  website: Website;
+  website: PostWebsite;
   exportFormat: "bbcode" | "markdown";
 }
 
@@ -22,76 +24,6 @@ const WEBSITE_LIST = [
 
 type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never;
 
-function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
-  const link = user.data.links[website];
-  if (link && "username" in link && link.username) {
-    return link.username;
-  }
-  throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
-}
-
-function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): boolean {
-  const { preferredLink } = user.data;
-  return !preferredLink || preferredLink == website;
-}
-
-function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, lang: Lang): string {
-  const { links, preferredLink } = user.data;
-  switch (website) {
-    case "eka":
-      if ("eka" in links) {
-        return `:icon${getUsernameForWebsite(user, "eka")}:`;
-      }
-      break;
-    case "furaffinity":
-      if ("furaffinity" in links) {
-        return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
-      }
-      break;
-    case "weasyl":
-      if ("weasyl" in links) {
-        return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
-      } else if (isPreferredWebsite(user, "furaffinity")) {
-        return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
-      } else if (isPreferredWebsite(user, "inkbunny")) {
-        return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
-      } else if (isPreferredWebsite(user, "sofurry")) {
-        return `<sf:${getUsernameForWebsite(user, "sofurry")}>`;
-      }
-      break;
-    case "inkbunny":
-      if ("inkbunny" in links) {
-        return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
-      } else if (isPreferredWebsite(user, "furaffinity")) {
-        return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
-      } else if (isPreferredWebsite(user, "sofurry")) {
-        return `[sf]${getUsernameForWebsite(user, "sofurry")}[/sf]`;
-      } else if (isPreferredWebsite(user, "weasyl")) {
-        return `[weasyl]${getUsernameForWebsite(user, "weasyl")}[/weasyl]`;
-      }
-      break;
-    case "sofurry":
-      if ("sofurry" in links) {
-        return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
-      } else if (isPreferredWebsite(user, "furaffinity")) {
-        return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
-      } else if (isPreferredWebsite(user, "inkbunny")) {
-        return `ib!${getUsernameForWebsite(user, "inkbunny")}`;
-      }
-      break;
-    default:
-      const unknown: never = website;
-      throw new Error(`Unhandled export website "${unknown}"`);
-  }
-  if (preferredLink) {
-    const preferred = links[preferredLink]!;
-    return `[${getUsernameForLang(user, lang)}](${preferred.link})`;
-  }
-  throw new Error(
-    `No matching "${website}" link for user "${user.id}" (consider setting their "preferredLink" property)`,
-  );
-}
-
 type Props = {
   story: CollectionEntry<"stories">;
 };
@@ -111,18 +43,21 @@ export const getStaticPaths: GetStaticPaths = async () => {
 };
 
 export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
-  const { lang } = story.data;
-  const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters);
-  const authorsList = await getEntries(story.data.authors);
-  const commissionersList = story.data.commissioner && (await getEntries(story.data.commissioner));
-  const requestersList = story.data.requester && (await getEntries(story.data.requester));
+  try {
+    const { lang } = story.data;
+    const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters);
+    const authorsList = await getEntries(story.data.authors);
+    const commissionersList = story.data.commissioner && (await getEntries(story.data.commissioner));
+    const requestersList = story.data.requester && (await getEntries(story.data.requester));
 
-  const description = Object.fromEntries(
-    WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
-      const u = (user: CollectionEntry<"users">) =>
-        isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website, lang);
-      const storyDescription = (
-        [
+    const description = await Promise.all(
+      WEBSITE_LIST.map(async ({ website, exportFormat }) => {
+        const exportWebsite: ExportWebsiteName = website;
+        const u = (user: CollectionEntry<"users">) =>
+          isAnonymousUser(user)
+            ? getUsernameForLang(user, lang)
+            : getWebsiteLinkForUser(user, exportWebsite, (user) => getUsernameForLang(user, lang));
+        const storyDescription = await [
           story.data.description,
           `*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`,
           t(
@@ -142,62 +77,76 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
               "export_story/commissioned_by",
               commissionersList.map((commissioner) => u(commissioner)),
             ),
-          ...copyrightedCharacters.map(([user, characterList]) =>
-            characterList[0] == ""
-              ? t(lang, "characters/all_characters_are_copyrighted_by", u(user))
-              : t(lang, "characters/characters_are_copyrighted_by", u(user), characterList),
+          ...copyrightedCharacters.map(({ user, characters }) =>
+            t(lang, "characters/characters_are_copyrighted_by", u(user), characters[0] == "" ? [] : characters),
           ),
-        ].filter((data) => data) as string[]
-      )
-        .join("\n\n")
-        .replaceAll(
-          /\[([^\]]+)\]\((\/[^\)]+)\)/g,
-          (_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
-        );
-      switch (exportFormat) {
-        case "bbcode":
-          return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
-        case "markdown":
-          return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
-        default:
-          const unknown: never = exportFormat;
-          throw new Error(`Unknown export format "${unknown}"`);
-      }
-    }),
-  );
+        ].reduce(async (promise, data) => {
+          if (!data) {
+            return promise;
+          }
+          const acc = await promise;
+          const newData = await qualifyLocalURLsInMarkdown(data, lang, site, exportWebsite);
+          return acc ? `${acc}\n\n${newData}` : newData;
+        }, Promise.resolve(""));
+        switch (exportFormat) {
+          case "bbcode":
+            return { exportWebsite, description: markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n") };
+          case "markdown":
+            return { exportWebsite, description: storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim() };
+          default:
+            const unknown: never = exportFormat;
+            throw new Error(`Unknown export format "${unknown}"`);
+        }
+      }),
+    );
 
-  const storyHeader =
-    `${story.data.title}\n` +
-    `${t(
-      lang,
-      "story/authors",
-      authorsList.map((author) => getUsernameForLang(author, lang)),
-    )}\n` +
-    (requestersList
-      ? `${t(
-          lang,
-          "story/requested_by",
-          requestersList.map((requester) => getUsernameForLang(requester, lang)),
-        )}\n`
-      : "") +
-    (commissionersList
-      ? `${t(
-          lang,
-          "story/commissioned_by",
-          commissionersList.map((commissioner) => getUsernameForLang(commissioner, lang)),
-        )}\n`
-      : "");
+    const storyHeader =
+      `${story.data.title}\n` +
+      `${t(
+        lang,
+        "story/authors",
+        authorsList.map((author) => getUsernameForLang(author, lang)),
+      )}\n` +
+      (requestersList
+        ? `${t(
+            lang,
+            "story/requested_by",
+            requestersList.map((requester) => getUsernameForLang(requester, lang)),
+          )}\n`
+        : "") +
+      (commissionersList
+        ? `${t(
+            lang,
+            "story/commissioned_by",
+            commissionersList.map((commissioner) => getUsernameForLang(commissioner, lang)),
+          )}\n`
+        : "");
 
-  const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}`
-    .replaceAll(/\n\n\n+/g, "\n\n")
-    .trim();
+    const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}`
+      .replaceAll(/\n\n\n+/g, "\n\n")
+      .trim();
 
-  return new Response(
-    JSON.stringify({
-      story: storyText,
-      description,
-      thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
-    }),
-    { headers: { "Content-Type": "application/json; charset=utf-8" } },
-  );
+    return new Response(
+      JSON.stringify({
+        story: storyText,
+        description: description.reduce(
+          (acc, item) => {
+            acc[item.exportWebsite] = item.description;
+            return acc;
+          },
+          {} as Record<PostWebsite, string>,
+        ),
+        thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
+      }),
+      { headers: { "Content-Type": "application/json; charset=utf-8" } },
+    );
+  } catch (e) {
+    return new Response(
+      JSON.stringify({
+        message: (e as Error).message ?? null,
+        stack: (e as Error).stack ?? null,
+      }),
+      { status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } },
+    );
+  }
 };
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index 24165db..554eee0 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -6,6 +6,7 @@ import sanitizeHtml from "sanitize-html";
 import { t, type Lang } from "../i18n";
 import { markdownToPlaintext } from "../utils/markdown_to_plaintext";
 import { getUsernameForLang } from "../utils/get_username_for_lang";
+import { qualifyLocalURLsInMarkdown } from "../utils/qualify_local_urls_in_markdown";
 
 type FeedItem = RSSFeedItem &
   Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>;
@@ -28,6 +29,81 @@ const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
   return userName;
 };
 
+async function storyFeedItem(
+  site: URL | undefined,
+  data: EntryWithPubDate<"stories">["data"],
+  slug: CollectionEntry<"stories">["slug"],
+  body: string,
+): Promise<FeedItem> {
+  return {
+    title: `New story! "${data.title}"`,
+    pubDate: toNoonUTCDate(data.pubDate),
+    link: `/stories/${slug}`,
+    description:
+      `${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
+        /[\n ]+/g,
+        " ",
+      ),
+    categories: ["story"],
+    content: sanitizeHtml(
+      `<h1>${data.title}</h1>` +
+        `<p>${t(
+          data.lang,
+          "story/authors",
+          (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
+        )}</p>` +
+        (data.requester
+          ? `<p>${t(
+              data.lang,
+              "story/requested_by",
+              (await getEntries(data.requester)).map((requester) => getLinkForUser(requester, data.lang)),
+            )}</p>`
+          : "") +
+        (data.commissioner
+          ? `<p>${t(
+              data.lang,
+              "story/commissioned_by",
+              (await getEntries(data.commissioner)).map((commissioner) => getLinkForUser(commissioner, data.lang)),
+            )}</p>`
+          : "") +
+        `<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` +
+        `<hr>${await markdown(body)}` +
+        `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
+    ),
+  };
+}
+
+async function gameFeedItem(
+  site: URL | undefined,
+  data: EntryWithPubDate<"games">["data"],
+  slug: CollectionEntry<"games">["slug"],
+  body: string,
+): Promise<FeedItem> {
+  return {
+    title: `New game! "${data.title}"`,
+    pubDate: toNoonUTCDate(data.pubDate),
+    link: `/games/${slug}`,
+    description:
+      `${t(data.lang, "game/warnings", data.platforms, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
+        /[\n ]+/g,
+        " ",
+      ),
+    categories: ["game"],
+    content: sanitizeHtml(
+      `<h1>${data.title}</h1>` +
+        `<p>${t(
+          data.lang,
+          "story/authors",
+          (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
+        )}</p>` +
+        `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
+        `<hr><p><em>${data.contentWarning}</em></p>` +
+        `<hr>${await markdown(body)}` +
+        `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
+    ),
+  };
+}
+
 export const GET: APIRoute = async ({ site }) => {
   const stories = (
     (await getCollection(
@@ -47,75 +123,21 @@ export const GET: APIRoute = async ({ site }) => {
     title: "Gallery | Bad Manners",
     description: "Stories, games, and (possibly) more by Bad Manners",
     site: site!,
-    items: [
-      await Promise.all(
-        stories.map<Promise<FeedItem>>(async ({ data, slug, body }) => ({
-          title: `New story! "${data.title}"`,
-          pubDate: toNoonUTCDate(data.pubDate),
-          link: `/stories/${slug}`,
-          description:
-            `${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}\n\n${markdownToPlaintext(data.description)}`.replaceAll(
-              /[\n ]+/g,
-              " ",
-            ),
-          categories: ["story"],
-          content: sanitizeHtml(
-            `<h1>${data.title}</h1>` +
-              `<p>${t(
-                data.lang,
-                "story/authors",
-                (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
-              )}</p>` +
-              (data.requester
-                ? `<p>${t(
-                    data.lang,
-                    "story/requested_by",
-                    (await getEntries(data.requester)).map((requester) => getLinkForUser(requester, data.lang)),
-                  )}</p>`
-                : "") +
-              (data.commissioner
-                ? `<p>${t(
-                    data.lang,
-                    "story/commissioned_by",
-                    (await getEntries(data.commissioner)).map((commissioner) =>
-                      getLinkForUser(commissioner, data.lang),
-                    ),
-                  )}</p>`
-                : "") +
-              `<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` +
-              `<hr>${await markdown(body)}` +
-              `<hr>${await markdown(data.description)}`,
-          ),
+    items: await Promise.all(
+      [
+        stories.map(({ data, slug, body }) => ({
+          date: data.pubDate,
+          fn: () => storyFeedItem(site, data, slug, body),
         })),
-      ),
-      await Promise.all(
-        games.map<Promise<FeedItem>>(async ({ data, slug, body }) => ({
-          title: `New game! "${data.title}"`,
-          pubDate: toNoonUTCDate(data.pubDate),
-          link: `/games/${slug}`,
-          description:
-            `${t(data.lang, "game/warnings", data.platforms, data.contentWarning)}\n\n${markdownToPlaintext(data.description)}`.replaceAll(
-              /[\n ]+/g,
-              " ",
-            ),
-          categories: ["game"],
-          content: sanitizeHtml(
-            `<h1>${data.title}</h1>` +
-              `<p>${t(
-                data.lang,
-                "story/authors",
-                (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
-              )}</p>` +
-              `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
-              `<hr><p><em>${data.contentWarning}</em></p>` +
-              `<hr>${await markdown(body)}` +
-              `<hr>${await markdown(data.description)}`,
-          ),
+        games.map(({ data, slug, body }) => ({
+          date: data.pubDate,
+          fn: () => gameFeedItem(site, data, slug, body),
         })),
-      ),
-    ]
-      .flat()
-      .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
-      .slice(0, MAX_ITEMS),
+      ]
+        .flat()
+        .sort((a, b) => b.date.getTime() - a.date.getTime())
+        .slice(0, MAX_ITEMS)
+        .map((value) => value.fn()),
+    ),
   });
 };
diff --git a/src/pages/stories/[...slug].astro b/src/pages/stories/[...slug].astro
index 2f58526..51d4e58 100644
--- a/src/pages/stories/[...slug].astro
+++ b/src/pages/stories/[...slug].astro
@@ -22,7 +22,8 @@ const story = Astro.props;
 const readingTime = getReadingTime(story.body);
 if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 150) {
   console.warn(
-    `"wordCount" differs greatly from actual word count for published story ${story.data.title} ("${story.slug}") ` +
+    `WARNING: "wordCount" differs greatly from actual word count for published story ` +
+      `${story.data.title} ("${story.slug}") ` +
       `(expected ~${story.data.wordCount}, found ${readingTime.words})`,
   );
 }
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index 01c098a..ca11c38 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -1,9 +1,8 @@
 ---
 import type { GetStaticPaths, Page } from "astro";
 import { Image } from "astro:assets";
-import { getCollection } from "astro:content";
+import { getCollection, type CollectionEntry } from "astro:content";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
-import type { CollectionEntry } from "astro:content";
 import { t } from "../../i18n";
 
 type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } };
@@ -51,7 +50,7 @@ const totalPages = Math.ceil(page.total / page.size);
         ) : (
           <a
             class="text-link border-r border-stone-400 px-2 py-1 underline dark:border-stone-500"
-            href={page.url.current.replace(`/${page.currentPage}`, `/${p + 1}`)}
+            href={page.url.current.replace(`/stories/${page.currentPage}`, `/stories/${p + 1}`)}
           >
             {p + 1}
           </a>
@@ -114,7 +113,7 @@ const totalPages = Math.ceil(page.total / page.size);
         ) : (
           <a
             class="text-link border-r border-stone-400 px-2 py-1 underline dark:border-stone-500"
-            href={page.url.current.replace(`/${page.currentPage}`, `/${p + 1}`)}
+            href={page.url.current.replace(`/stories/${page.currentPage}`, `/stories/${p + 1}`)}
           >
             {p + 1}
           </a>
diff --git a/src/pages/stories/the-lost-of-the-marshes.astro b/src/pages/stories/the-lost-of-the-marshes.astro
index 5777dd2..acda096 100644
--- a/src/pages/stories/the-lost-of-the-marshes.astro
+++ b/src/pages/stories/the-lost-of-the-marshes.astro
@@ -24,7 +24,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
   <meta
     slot="head-description"
     property="og:description"
-    content="Bad Manners || The story of Quince, Nikili, and Suu."
+    content="The Lost of the Marshes || The story of Quince, Nikili, and Suu."
   />
   <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">{series.data.name}</h1>
   <p class="my-4">This is the main hub for the story of Quince, Nikili, and Suu, as well as all bonus content.</p>
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 8c4577e..5373f99 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -64,7 +64,7 @@ const categorizedTags = tagCategories
 
 if (uncategorizedTagsSet.size > 0) {
   const tagList = [...uncategorizedTagsSet];
-  console.warn("The following tags have no category:", tagList);
+  console.warn("WARNING: The following tags have no category:", tagList);
   // categorizedTags.push(["Uncategorized tags", "others", tagList]);
 }
 ---
@@ -83,7 +83,7 @@ if (uncategorizedTagsSet.size > 0) {
       {
         seriesCollection.map((series) => (
           <li>
-            <a class="text-link underline" href={series.data.url}>
+            <a class="text-link underline" href={series.data.link}>
               {series.data.name}
             </a>
           </li>
@@ -98,10 +98,14 @@ if (uncategorizedTagsSet.size > 0) {
           <h2 id={`category-${categoryId}`} class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
             {category}
           </h2>
-          <ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2">
+          <ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-3 px-3">
             {tagList.map(({ id, name, description }) => (
-              <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white">
-                <a class="hover:underline focus:underline" href={`/tags/${id}`} title={description}>
+              <li>
+                <a
+                  class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm hover:underline focus:underline dark:bg-bm-600 dark:text-white"
+                  href={`/tags/${id}`}
+                  title={description}
+                >
                   {name}
                 </a>
               </li>
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index 67278cb..4343132 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -6,8 +6,8 @@ import { Markdown } from "@astropub/md";
 import { slug } from "github-slugger";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
 import Prose from "../../components/Prose.astro";
-import { t } from "../../i18n";
-import { DEFAULT_LANG } from "../../i18n";
+import { t, DEFAULT_LANG } from "../../i18n";
+import { qualifyLocalURLsInMarkdown } from "../../utils/qualify_local_urls_in_markdown";
 
 type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } };
 
@@ -47,11 +47,11 @@ export const getStaticPaths: GetStaticPaths = async () => {
       category.data.tags.forEach(({ name, description, related }) => {
         related = related.filter((relatedTag) => {
           if (relatedTag == name) {
-            console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
+            console.warn(`WARNING: Tag "${name}" should not have itself as a related tag; removing...`);
             return false;
           }
           if (!tags.has(relatedTag)) {
-            console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
+            console.warn(`WARNING: Tag "${name}" has an unknown related tag "${relatedTag}"; removing...`);
             return false;
           }
           return true;
@@ -88,23 +88,34 @@ export const getStaticPaths: GetStaticPaths = async () => {
     }));
 };
 
-const { tag, description, stories, games, related } = Astro.props;
+const { props } = Astro;
+const description = props.description && (await qualifyLocalURLsInMarkdown(props.description, DEFAULT_LANG));
 if (!description) {
-  console.warn(`Tag "${tag}" has no description`);
+  console.warn(`WARNING: Tag "${props.tag}" has no description`);
 }
-const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stories.length, games.length);
+const totalWorksWithTag = t(
+  DEFAULT_LANG,
+  "tag/total_works_with_tag",
+  props.tag,
+  props.stories.length,
+  props.games.length,
+);
 ---
 
-<GalleryLayout pageTitle={`Works tagged "${tag}"`}>
-  <meta slot="head-description" content={`Bad Manners || ${totalWorksWithTag || tag}`} property="og:description" />
-  <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{tag}"</h1>
+<GalleryLayout pageTitle={`Works tagged "${props.tag}"`}>
+  <meta
+    slot="head-description"
+    content={`Bad Manners || ${totalWorksWithTag || props.tag}`}
+    property="og:description"
+  />
+  <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{props.tag}"</h1>
   <div class="my-4">
     <Prose>
       {description ? <Markdown of={description} /> : null}
       {
-        related?.length ? (
+        props.related?.length ? (
           <p
-            set:html={`See also: ${related.map((relatedTag) => `<a href="/tags/${slug(relatedTag)}">${relatedTag}</a>`).join(", ")}.`}
+            set:html={`See also: ${props.related.map((relatedTag) => `<a href="/tags/${slug(relatedTag)}">${relatedTag}</a>`).join(", ")}.`}
           />
         ) : null
       }
@@ -112,13 +123,13 @@ const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stori
     </Prose>
   </div>
   {
-    stories.length > 0 && (
+    props.stories.length > 0 && (
       <section class="my-2" aria-labelledby="content-stories">
         <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 items-start justify-center gap-4 text-center md:justify-normal">
-          {stories.map((story) => (
+          {props.stories.map((story) => (
             <li class="break-inside-avoid">
               <a
                 class="text-link hover:underline focus:underline"
@@ -135,7 +146,17 @@ const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stori
                     />
                   </div>
                 ) : null}
-                <div class="max-w-48 text-sm">{story.data.title}</div>
+                <div class="max-w-[192px] text-sm">
+                  <span>{story.data.title}</span>
+                  <br />
+                  <span class="italic">
+                    {story.data.pubDate.toLocaleDateString("en-US", {
+                      month: "short",
+                      day: "numeric",
+                      year: "numeric",
+                    })}
+                  </span>
+                </div>
               </a>
             </li>
           ))}
@@ -144,13 +165,13 @@ const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stori
     )
   }
   {
-    games.length > 0 && (
+    props.games.length > 0 && (
       <section class="my-2" aria-labelledby="content-games">
         <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 items-start justify-center gap-4 text-center md:justify-normal">
-          {games.map((game) => (
+          {props.games.map((game) => (
             <li class="break-inside-avoid">
               <a
                 class="text-link hover:underline focus:underline"
@@ -167,7 +188,13 @@ const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stori
                     />
                   </div>
                 ) : null}
-                <div class="max-w-48 text-sm">{game.data.title}</div>
+                <div class="max-w-[192px] text-sm">
+                  <span>{game.data.title}</span>
+                  <br />
+                  <span class="italic">
+                    {game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+                  </span>
+                </div>
               </a>
             </li>
           ))}
diff --git a/src/utils/format_copyrighted_characters.ts b/src/utils/format_copyrighted_characters.ts
index cd3ff14..31a9bbf 100644
--- a/src/utils/format_copyrighted_characters.ts
+++ b/src/utils/format_copyrighted_characters.ts
@@ -7,14 +7,15 @@ export async function formatCopyrightedCharacters(copyrightedCharacters: Copyrig
       Object.keys(copyrightedCharacters).reduce(
         (acc, character) => {
           const user = copyrightedCharacters[character];
-          if (!(user.id in acc)) {
-            acc[user.id] = [getEntry(user), []];
+          if (user.id in acc) {
+            acc[user.id][1].push(character);
+          } else {
+            acc[user.id] = [getEntry(user), [character]];
           }
-          acc[user.id][1].push(character);
           return acc;
         },
-        {} as Record<string, [Promise<CollectionEntry<"users">>, string[]]>,
+        {} as Record<CollectionEntry<"users">["id"], [Promise<CollectionEntry<"users">>, string[]]>,
       ),
-    ).map(async ([userPromise, characters]) => [await userPromise, characters] as [CollectionEntry<"users">, string[]]),
+    ).map(async ([userPromise, characters]) => ({ user: await userPromise, characters })),
   );
 }
diff --git a/src/utils/get_username_for_lang.ts b/src/utils/get_username_for_lang.ts
index eef4f74..a81328a 100644
--- a/src/utils/get_username_for_lang.ts
+++ b/src/utils/get_username_for_lang.ts
@@ -10,7 +10,7 @@ export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang):
     throw new Error(`No "${lang}" translation for user "${user.id}"`);
   }
   if (lang !== DEFAULT_LANG) {
-    console.warn(`Name "${name}" for user "${user.id}" isn't translated to "${lang}"; using default`);
+    console.warn(`WARNING: Name "${name}" for user "${user.id}" isn't translated to "${lang}"; using default...`);
   }
   return name;
 }
diff --git a/src/utils/get_website_link_for_user.ts b/src/utils/get_website_link_for_user.ts
new file mode 100644
index 0000000..3539ce2
--- /dev/null
+++ b/src/utils/get_website_link_for_user.ts
@@ -0,0 +1,114 @@
+import type { CollectionEntry } from "astro:content";
+import { DEFAULT_LANG, type UserWebsite } from "../content/config";
+import { getUsernameForLang } from "./get_username_for_lang";
+
+type WebsiteUserData<W extends UserWebsite> = CollectionEntry<"users">["data"]["links"][W];
+
+function getUserDataForWebsite<W extends UserWebsite>(user: CollectionEntry<"users">, website: W) {
+  const link: WebsiteUserData<W> | undefined = user.data.links[website];
+  if (link) {
+    return link;
+  }
+  throw new Error(`Cannot get "${website}" data for user "${user.id}"`);
+}
+
+const isPreferredWebsite = (user: CollectionEntry<"users">, website: UserWebsite) =>
+  !!user.data.preferredLink && user.data.preferredLink == website;
+
+/**
+ * Format a user to the special string that references the user in a specified website -
+ * for example, by adding the `@` in front of a Twitter handle.
+ * Otherwise, it will be formatted as a Markdown link to the user's preferred link.
+ * @param user User to format.
+ * @param website Which website to format with.
+ * The function will attempt to match the user's link for the given website,
+ * or try to use any special formatting rules corresponding with the user's preferred link.
+ * @param usernameFn Optional function that labels the user's preferred link.
+ * By default, this will be a function that returns the username in English.
+ * @returns
+ */
+export function getWebsiteLinkForUser(
+  user: CollectionEntry<"users">,
+  website: UserWebsite | undefined,
+  usernameFn: (user: CollectionEntry<"users">) => string = (user) => getUsernameForLang(user, DEFAULT_LANG),
+): string {
+  if (website === "website") {
+    website = undefined;
+  }
+  const { links, preferredLink } = user.data;
+  switch (website) {
+    case undefined:
+      break;
+    case "eka":
+      if ("eka" in links) {
+        return `:icon${getUserDataForWebsite(user, "eka").username}:`;
+      }
+      break;
+    case "furaffinity":
+      if ("furaffinity" in links) {
+        return `:icon${getUserDataForWebsite(user, "furaffinity").username}:`;
+      }
+      break;
+    case "weasyl":
+      if ("weasyl" in links) {
+        return `<!~${getUserDataForWebsite(user, "weasyl").username.replaceAll(" ", "")}>`;
+      } else if (isPreferredWebsite(user, "furaffinity")) {
+        return `<fa:${getUserDataForWebsite(user, "furaffinity").username.replaceAll("_", "")}>`;
+      } else if (isPreferredWebsite(user, "inkbunny")) {
+        return `<ib:${getUserDataForWebsite(user, "inkbunny").username}>`;
+      } else if (isPreferredWebsite(user, "sofurry")) {
+        return `<sf:${getUserDataForWebsite(user, "sofurry").username}>`;
+      }
+      break;
+    case "inkbunny":
+      if ("inkbunny" in links) {
+        return `[iconname]${getUserDataForWebsite(user, "inkbunny").username}[/iconname]`;
+      } else if (isPreferredWebsite(user, "furaffinity")) {
+        return `[fa]${getUserDataForWebsite(user, "furaffinity").username}[/fa]`;
+      } else if (isPreferredWebsite(user, "sofurry")) {
+        return `[sf]${getUserDataForWebsite(user, "sofurry").username}[/sf]`;
+      } else if (isPreferredWebsite(user, "weasyl")) {
+        return `[weasyl]${getUserDataForWebsite(user, "weasyl").username}[/weasyl]`;
+      }
+      break;
+    case "sofurry":
+      if ("sofurry" in links) {
+        return `:icon${getUserDataForWebsite(user, "sofurry").username}:`;
+      } else if (isPreferredWebsite(user, "furaffinity")) {
+        return `fa!${getUserDataForWebsite(user, "furaffinity").username}`;
+      } else if (isPreferredWebsite(user, "inkbunny")) {
+        return `ib!${getUserDataForWebsite(user, "inkbunny").username}`;
+      }
+      break;
+    case "twitter":
+      if ("twitter" in links) {
+        return `@${getUserDataForWebsite(user, "twitter").username}`;
+      }
+      break;
+    case "mastodon":
+      if ("mastodon" in links) {
+        const { handle, instance } = getUserDataForWebsite(user, "mastodon");
+        return `@${handle}@${instance}`;
+      }
+      break;
+    case "bluesky":
+      if ("bluesky" in links) {
+        return `@${getUserDataForWebsite(user, "bluesky").username}`;
+      }
+      break;
+    case "itaku":
+      if ("itaku" in links) {
+        return `@${getUserDataForWebsite(user, "itaku").username}`;
+      }
+      break;
+    default:
+      const unknown: never = website;
+      throw new Error(`Unhandled export website "${unknown}"`);
+  }
+  if (preferredLink) {
+    return `[${usernameFn(user)}](${links[preferredLink]!.link})`;
+  }
+  throw new Error(
+    `No matching${website ? ` "${website}" ` : " "}link for user "${user.id}". (hint: consider setting their "preferredLink" property)`,
+  );
+}
diff --git a/src/utils/qualify_local_urls_in_markdown.ts b/src/utils/qualify_local_urls_in_markdown.ts
new file mode 100644
index 0000000..559a0d8
--- /dev/null
+++ b/src/utils/qualify_local_urls_in_markdown.ts
@@ -0,0 +1,110 @@
+import { getCollection, getEntry } from "astro:content";
+import type { Lang, PostWebsite } from "../content/config";
+import { getWebsiteLinkForUser } from "./get_website_link_for_user";
+import { getUsernameForLang } from "./get_username_for_lang";
+
+type MatchGroups =
+  | {
+      text: string;
+      link: string;
+    }
+  | {
+      text: string;
+      link: string;
+      contentPrefix: "stories" | "games" | "users";
+      slug: string;
+    };
+
+const LOCAL_URL_REGEX =
+  /\[(?<text>[^\]]*)\]\((?<link>(?:\/(?<contentPrefix>stories|games|users)(?!\/?\)|\/[1-9]\d*\/?\))\/(?<slug>[^\)]+))|(?:\/[^\)]+?))\/?\)/g;
+
+const SERIES_URLS_REGEX_PROMISE = getCollection("series").then(
+  (series) => new RegExp(`^(${series.map(({ data }) => data.link.replace(/\/$/, "")).join("|")})\/?$`),
+);
+
+/**
+ * Given a Markdown text, finds any local URLs (eg. `[click me](/stories/foo)`) and replaces them with the appropriate
+ * fully qualified link (eg. `[click me](https://example.com/stories/foo)`).
+ * @param originalText The Markdown text to modify.
+ * @param lang The language for translating usernames.
+ * @param site The base URL of the gallery, to append to the start of local links and permalinks.
+ * If undefined, this function will only replace `/users/...` links, while still validating the others.
+ * @param website Which website the Markdown text will be rendered to. If defined, any content links
+ * (stories, games, etc.) will be replaced by the appropriate link on that website whenever possible.
+ * If undefined, this function will only replace `/users/...` links (and permalinks if `site` is set),
+ * while still validating the others.
+ */
+export async function qualifyLocalURLsInMarkdown(originalText: string, lang: Lang, site?: URL, website?: PostWebsite) {
+  const replacements: string[] = [];
+  for (const match of originalText.matchAll(new RegExp(LOCAL_URL_REGEX))) {
+    const groups = match.groups as MatchGroups | undefined;
+    if (groups?.text === undefined || groups?.link === undefined) {
+      throw new Error(`Cannot qualify invalid local URL ${match[0]}`);
+    }
+    const { text, link } = groups;
+    // Any links that match a series `link` are handled separately.
+    if ((await SERIES_URLS_REGEX_PROMISE).test(link)) {
+      replacements.push(site ? `[${text}](${new URL(link, site).toString()})` : match[0]);
+      continue;
+    }
+    // Check if this is a special link (story, game, or user)
+    if ("contentPrefix" in groups && groups.contentPrefix) {
+      const { contentPrefix, slug } = groups;
+      switch (contentPrefix) {
+        case "stories":
+          const story = await getEntry("stories", slug);
+          if (!story) {
+            throw new Error(`Couldn't find story with slug "${slug}"`);
+          }
+          if (typeof website === "string" && story.data.posts[website]?.link) {
+            replacements.push(`[${text}](${story.data.posts[website].link})`);
+            continue;
+          }
+          break;
+        case "games":
+          const game = await getEntry("games", slug);
+          if (!game) {
+            throw new Error(`Couldn't find game with slug "${slug}"`);
+          }
+          if (typeof website === "string" && game.data.posts[website]?.link) {
+            replacements.push(`[${text}](${game.data.posts[website].link})`);
+            continue;
+          }
+          break;
+        case "users":
+          const user = await getEntry("users", slug);
+          if (!user) {
+            throw new Error(`Couldn't find user with id "${slug}"`);
+          }
+          // If there's a label in the link, use that if possible
+          if (text) {
+            if (typeof website === "string" && user.data.links[website]?.link) {
+              replacements.push(`[${text}](${user.data.links[website].link})`);
+              continue;
+            } else if (user.data.preferredLink) {
+              replacements.push(`[${text}](${user.data.links[user.data.preferredLink]!.link})`);
+              continue;
+            }
+          }
+          // Otherwise (i.e. label is empty), use the username for the website
+          replacements.push(getWebsiteLinkForUser(user, website, (user) => getUsernameForLang(user, lang)));
+          continue;
+        default:
+          const unknown: never = contentPrefix;
+          console.warn(`WARNING: Unknown local link qualifier ${unknown}; ignoring...`);
+      }
+    }
+    replacements.push(website && site ? `[${text}](${new URL(link, site).toString()})` : match[0]);
+  }
+  const newText = originalText.replaceAll(new RegExp(LOCAL_URL_REGEX), () => {
+    const replacement = replacements.shift();
+    if (!replacement) {
+      throw new Error(`Replacements array length didn't match length of regex matches! (too few elements)`);
+    }
+    return replacement;
+  });
+  if (replacements.length > 0) {
+    throw new Error(`Replacements array length didn't match length of regex matches! (too many elements)`);
+  }
+  return newText;
+}