diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index 82e5582..966bf65 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -1,3 +1,8 @@
 {
-  "recommendations": ["astro-build.astro-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"]
+  "recommendations": [
+    "astro-build.astro-vscode",
+    "bradlc.vscode-tailwindcss",
+    "esbenp.prettier-vscode",
+    "redhat.vscode-yaml"
+  ]
 }
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 43f71ef..57dc4ec 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -2,6 +2,25 @@
   "files.associations": {
     "*.css": "tailwindcss"
   },
+  "json.schemas": [
+    {
+      "fileMatch": ["/src/content/series/**"],
+      "url": "./.astro/collections/series.schema.json"
+    },
+    {
+      "fileMatch": ["/src/content/tag-categories/**"],
+      "url": "./.astro/collections/tag-categories.schema.json"
+    },
+    {
+      "fileMatch": ["/src/content/users/**"],
+      "url": "./.astro/collections/users.schema.json"
+    }
+  ],
+  "yaml.schemas": {
+    "./.astro/collections/series.schema.json": "/src/content/series/**",
+    "./.astro/collections/tag-categories.schema.json": "/src/content/tag-categories/**",
+    "./.astro/collections/users.schema.json": "/src/content/users/**"
+  },
   "[astro]": {
     "editor.defaultFormatter": "esbenp.prettier-vscode"
   },
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index ea120b0..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2024 Bad Manners
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..1d4c39d
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1 @@
+See [public/licenses.txt](public/licenses.txt)
diff --git a/README.md b/README.md
index 3fd7a82..eec5f18 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ Static website built in Astro + Typescript + TailwindCSS.
 ## Requirements
 
 - Node.js 20+
-- (optional) LFTP, for the remote deployment script.
+- (optional) rsync, for remote deployment.
 - (optional) LibreOffice, for the story export script.
 
 ## Development
@@ -31,7 +31,7 @@ npm run prettier  # Prettier formatting
 Requires `libreoffice` to be installed and in your path.
 
 ```bash
-npm run export-story -- --output-dir ~/Documents/TO_UPLOAD slug-for-story-to-export
+npm run export-story -- -o ~/Documents/TO_UPLOAD slug-for-story-to-export
 ```
 
 ### Build and deploy to remote
@@ -40,19 +40,8 @@ npm run export-story -- --output-dir ~/Documents/TO_UPLOAD slug-for-story-to-exp
 npm run build
 ```
 
-Then, if you're using LFTP:
-
-1. Create a new `.env` file at the root of the project with your credentials (SSH, SFTP, WebDav, etc.) if you haven't already:
-
-```env
-DEPLOY_LFTP_HOST=https://example-webdav-server.com
-DEPLOY_LFTP_USER=example_user
-DEPLOY_LFTP_PASSWORD=sup3r_s3cr3t_password
-DEPLOY_LFTP_TARGETFOLDER=sites/gallery.badmanners.xyz/
-```
-
-2. Run the deploy command:
+Then, after configuring the `gallerybm` host (or the name of your choosing) in `~/.ssh/config`:
 
 ```bash
-npm run deploy-lftp
+rsync --delete -acP dist/ gallerybm:/home/public
 ```
diff --git a/astro.config.mjs b/astro.config.mjs
index d5c6758..12c378e 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -19,6 +19,7 @@ export default defineConfig({
   build: {
     assets: "assets",
   },
+  outDir: "./dist",
   redirects: {
     "/stories": "/stories/1",
   },
diff --git a/examples/game.md b/examples/game.md
index b45edc0..7d2e131 100644
--- a/examples/game.md
+++ b/examples/game.md
@@ -17,7 +17,7 @@ tags: []
 # series: the-lost-of-the-marshes
 # relatedStories: []
 # relatedGames: []
-# lang: eng
+# lang: en
 ---
 
 The game content (i.e. embed) goes here.
diff --git a/examples/story.md b/examples/story.md
index e7a3bfa..aded0f7 100644
--- a/examples/story.md
+++ b/examples/story.md
@@ -25,7 +25,7 @@ tags: []
 #   Some funny summary
 # relatedStories: []
 # relatedGames: []
-# lang: eng
+# lang: en
 ---
 
 The story goes here.
diff --git a/examples/user.yaml b/examples/user.yaml
index ba7eadf..bae2e71 100644
--- a/examples/user.yaml
+++ b/examples/user.yaml
@@ -1,6 +1,6 @@
 name: Nameless User
 nameLang:
-  eng: Nameless
+  en: Nameless
   tok: jan Nenle pi nimi ala
 # avatar: /src/assets/images/logo_bm.png
 links:
diff --git a/package-lock.json b/package-lock.json
index 52d78bd..96648c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,26 +1,26 @@
 {
   "name": "gallery-badmanners-xyz",
-  "version": "1.6.0",
+  "version": "1.6.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery-badmanners-xyz",
-      "version": "1.6.0",
+      "version": "1.6.1",
       "dependencies": {
-        "@astrojs/check": "^0.8.2",
+        "@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.12.2",
+        "astro": "^4.13.1",
         "astro-pagefind": "^1.6.0",
         "github-slugger": "^2.0.0",
-        "marked": "^12.0.1",
+        "marked": "^12.0.2",
         "pagefind": "^1.1.0",
         "reading-time": "^1.5.0",
         "sanitize-html": "^2.13.0",
-        "tailwindcss": "^3.4.6",
+        "tailwindcss": "^3.4.7",
         "tiny-decode": "^0.1.3",
         "typescript": "^5.5.4"
       },
@@ -32,7 +32,7 @@
         "prettier": "^3.3.3",
         "prettier-plugin-astro": "^0.14.1",
         "prettier-plugin-tailwindcss": "^0.6.5",
-        "tsx": "^4.16.2"
+        "tsx": "^4.16.5"
       }
     },
     "../astro-pagefind/packages/astro-pagefind": {
@@ -83,12 +83,12 @@
       }
     },
     "node_modules/@astrojs/check": {
-      "version": "0.8.2",
-      "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.8.2.tgz",
-      "integrity": "sha512-L0V9dGb2PGvK9Mf3kby99Y+qm7EqxaC9tN1MVCvaqp/3pPPZBadR4XAySHipxXqQsxwJS25WQow8/1kMl1e25g==",
+      "version": "0.9.2",
+      "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.2.tgz",
+      "integrity": "sha512-6rWxtJTbd/ctdAlmla0CAvloGaai5IUTG0K21kctJHHGKJKnGH6Xana7m0zNOtHpVPEJi1SgC/TcsN+ltYt0Cg==",
       "license": "MIT",
       "dependencies": {
-        "@astrojs/language-server": "^2.12.1",
+        "@astrojs/language-server": "^2.13.2",
         "chokidar": "^3.5.3",
         "fast-glob": "^3.3.1",
         "kleur": "^4.1.5",
@@ -102,9 +102,9 @@
       }
     },
     "node_modules/@astrojs/compiler": {
-      "version": "2.9.2",
-      "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.9.2.tgz",
-      "integrity": "sha512-Vpu0Ffsj8SoV+N0DFHlxxOMKHwSC9059Xy/OlG1t6uFYSoJXxkBC2WyF6igO7x10V+8uJrhOxaXr3nA90kJXow==",
+      "version": "2.10.2",
+      "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.10.2.tgz",
+      "integrity": "sha512-bvH+v8AirwpRWCkYJEyWYdc5Cs/BjG2ZTxIJzttHilXgfKJAdW2496KsUQKzf5j2tOHtaHXKKn9hb9WZiBGpEg==",
       "license": "MIT"
     },
     "node_modules/@astrojs/internal-helpers": {
@@ -114,12 +114,12 @@
       "license": "MIT"
     },
     "node_modules/@astrojs/language-server": {
-      "version": "2.12.1",
-      "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.12.1.tgz",
-      "integrity": "sha512-CCibE6XwSmrZEKlPDr48LZJN7NWxOurOJK1yOzqZFMNV8Y6DIqF6s1e60gbNNHMZkthWYBNTPno4Ni/XyviinQ==",
+      "version": "2.13.2",
+      "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.13.2.tgz",
+      "integrity": "sha512-l435EZLKjaUO/6iewJ7xqd3eHf3zAosVWG4woILbxluQcianBoNPepnnqAg7uUriZUaC44ae5v0Q+AfB8UI64g==",
       "license": "MIT",
       "dependencies": {
-        "@astrojs/compiler": "^2.9.1",
+        "@astrojs/compiler": "^2.10.2",
         "@jridgewell/sourcemap-codec": "^1.4.15",
         "@volar/kit": "~2.4.0-alpha.15",
         "@volar/language-core": "~2.4.0-alpha.15",
@@ -256,30 +256,30 @@
       }
     },
     "node_modules/@babel/compat-data": {
-      "version": "7.24.9",
-      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.9.tgz",
-      "integrity": "sha512-e701mcfApCJqMMueQI0Fb68Amflj83+dvAvHawoBpAz+GDjCIyGHzNwnefjsWJ3xiYAqqiQFoWbspGYBdb2/ng==",
+      "version": "7.25.2",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz",
+      "integrity": "sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==",
       "license": "MIT",
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/core": {
-      "version": "7.24.9",
-      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz",
-      "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==",
+      "version": "7.25.2",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
+      "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
       "license": "MIT",
       "dependencies": {
         "@ampproject/remapping": "^2.2.0",
         "@babel/code-frame": "^7.24.7",
-        "@babel/generator": "^7.24.9",
-        "@babel/helper-compilation-targets": "^7.24.8",
-        "@babel/helper-module-transforms": "^7.24.9",
-        "@babel/helpers": "^7.24.8",
-        "@babel/parser": "^7.24.8",
-        "@babel/template": "^7.24.7",
-        "@babel/traverse": "^7.24.8",
-        "@babel/types": "^7.24.9",
+        "@babel/generator": "^7.25.0",
+        "@babel/helper-compilation-targets": "^7.25.2",
+        "@babel/helper-module-transforms": "^7.25.2",
+        "@babel/helpers": "^7.25.0",
+        "@babel/parser": "^7.25.0",
+        "@babel/template": "^7.25.0",
+        "@babel/traverse": "^7.25.2",
+        "@babel/types": "^7.25.2",
         "convert-source-map": "^2.0.0",
         "debug": "^4.1.0",
         "gensync": "^1.0.0-beta.2",
@@ -304,12 +304,12 @@
       }
     },
     "node_modules/@babel/generator": {
-      "version": "7.24.10",
-      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.10.tgz",
-      "integrity": "sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==",
+      "version": "7.25.0",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz",
+      "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==",
       "license": "MIT",
       "dependencies": {
-        "@babel/types": "^7.24.9",
+        "@babel/types": "^7.25.0",
         "@jridgewell/gen-mapping": "^0.3.5",
         "@jridgewell/trace-mapping": "^0.3.25",
         "jsesc": "^2.5.1"
@@ -331,12 +331,12 @@
       }
     },
     "node_modules/@babel/helper-compilation-targets": {
-      "version": "7.24.8",
-      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.8.tgz",
-      "integrity": "sha512-oU+UoqCHdp+nWVDkpldqIQL/i/bvAv53tRqLG/s+cOXxe66zOYLU7ar/Xs3LdmBihrUMEUhwu6dMZwbNOYDwvw==",
+      "version": "7.25.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
+      "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
       "license": "MIT",
       "dependencies": {
-        "@babel/compat-data": "^7.24.8",
+        "@babel/compat-data": "^7.25.2",
         "@babel/helper-validator-option": "^7.24.8",
         "browserslist": "^4.23.1",
         "lru-cache": "^5.1.1",
@@ -355,43 +355,6 @@
         "semver": "bin/semver.js"
       }
     },
-    "node_modules/@babel/helper-environment-visitor": {
-      "version": "7.24.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz",
-      "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.24.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-function-name": {
-      "version": "7.24.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz",
-      "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==",
-      "license": "MIT",
-      "dependencies": {
-        "@babel/template": "^7.24.7",
-        "@babel/types": "^7.24.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
-    "node_modules/@babel/helper-hoist-variables": {
-      "version": "7.24.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz",
-      "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.24.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
     "node_modules/@babel/helper-module-imports": {
       "version": "7.24.7",
       "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz",
@@ -406,16 +369,15 @@
       }
     },
     "node_modules/@babel/helper-module-transforms": {
-      "version": "7.24.9",
-      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.9.tgz",
-      "integrity": "sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==",
+      "version": "7.25.2",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz",
+      "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==",
       "license": "MIT",
       "dependencies": {
-        "@babel/helper-environment-visitor": "^7.24.7",
         "@babel/helper-module-imports": "^7.24.7",
         "@babel/helper-simple-access": "^7.24.7",
-        "@babel/helper-split-export-declaration": "^7.24.7",
-        "@babel/helper-validator-identifier": "^7.24.7"
+        "@babel/helper-validator-identifier": "^7.24.7",
+        "@babel/traverse": "^7.25.2"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -446,18 +408,6 @@
         "node": ">=6.9.0"
       }
     },
-    "node_modules/@babel/helper-split-export-declaration": {
-      "version": "7.24.7",
-      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz",
-      "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==",
-      "license": "MIT",
-      "dependencies": {
-        "@babel/types": "^7.24.7"
-      },
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
     "node_modules/@babel/helper-string-parser": {
       "version": "7.24.8",
       "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
@@ -486,13 +436,13 @@
       }
     },
     "node_modules/@babel/helpers": {
-      "version": "7.24.8",
-      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.8.tgz",
-      "integrity": "sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ==",
+      "version": "7.25.0",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz",
+      "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==",
       "license": "MIT",
       "dependencies": {
-        "@babel/template": "^7.24.7",
-        "@babel/types": "^7.24.8"
+        "@babel/template": "^7.25.0",
+        "@babel/types": "^7.25.0"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -514,10 +464,13 @@
       }
     },
     "node_modules/@babel/parser": {
-      "version": "7.24.8",
-      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.8.tgz",
-      "integrity": "sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==",
+      "version": "7.25.3",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz",
+      "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==",
       "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.25.2"
+      },
       "bin": {
         "parser": "bin/babel-parser.js"
       },
@@ -541,16 +494,16 @@
       }
     },
     "node_modules/@babel/plugin-transform-react-jsx": {
-      "version": "7.24.7",
-      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.24.7.tgz",
-      "integrity": "sha512-+Dj06GDZEFRYvclU6k4bme55GKBEWUmByM/eoKuqg4zTNQHiApWRhQph5fxQB2wAEFvRzL1tOEj1RJ19wJrhoA==",
+      "version": "7.25.2",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.2.tgz",
+      "integrity": "sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==",
       "license": "MIT",
       "dependencies": {
         "@babel/helper-annotate-as-pure": "^7.24.7",
         "@babel/helper-module-imports": "^7.24.7",
-        "@babel/helper-plugin-utils": "^7.24.7",
+        "@babel/helper-plugin-utils": "^7.24.8",
         "@babel/plugin-syntax-jsx": "^7.24.7",
-        "@babel/types": "^7.24.7"
+        "@babel/types": "^7.25.2"
       },
       "engines": {
         "node": ">=6.9.0"
@@ -560,33 +513,30 @@
       }
     },
     "node_modules/@babel/template": {
-      "version": "7.24.7",
-      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz",
-      "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==",
+      "version": "7.25.0",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz",
+      "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==",
       "license": "MIT",
       "dependencies": {
         "@babel/code-frame": "^7.24.7",
-        "@babel/parser": "^7.24.7",
-        "@babel/types": "^7.24.7"
+        "@babel/parser": "^7.25.0",
+        "@babel/types": "^7.25.0"
       },
       "engines": {
         "node": ">=6.9.0"
       }
     },
     "node_modules/@babel/traverse": {
-      "version": "7.24.8",
-      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.8.tgz",
-      "integrity": "sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==",
+      "version": "7.25.3",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz",
+      "integrity": "sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==",
       "license": "MIT",
       "dependencies": {
         "@babel/code-frame": "^7.24.7",
-        "@babel/generator": "^7.24.8",
-        "@babel/helper-environment-visitor": "^7.24.7",
-        "@babel/helper-function-name": "^7.24.7",
-        "@babel/helper-hoist-variables": "^7.24.7",
-        "@babel/helper-split-export-declaration": "^7.24.7",
-        "@babel/parser": "^7.24.8",
-        "@babel/types": "^7.24.8",
+        "@babel/generator": "^7.25.0",
+        "@babel/parser": "^7.25.3",
+        "@babel/template": "^7.25.0",
+        "@babel/types": "^7.25.2",
         "debug": "^4.3.1",
         "globals": "^11.1.0"
       },
@@ -595,9 +545,9 @@
       }
     },
     "node_modules/@babel/types": {
-      "version": "7.24.9",
-      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.9.tgz",
-      "integrity": "sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==",
+      "version": "7.25.2",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz",
+      "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==",
       "license": "MIT",
       "dependencies": {
         "@babel/helper-string-parser": "^7.24.8",
@@ -1582,9 +1532,10 @@
       }
     },
     "node_modules/@jridgewell/sourcemap-codec": {
-      "version": "1.4.15",
-      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
-      "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
+      "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
+      "license": "MIT"
     },
     "node_modules/@jridgewell/trace-mapping": {
       "version": "0.3.25",
@@ -1863,9 +1814,9 @@
       ]
     },
     "node_modules/@shikijs/core": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.11.1.tgz",
-      "integrity": "sha512-Qsn8h15SWgv5TDRoDmiHNzdQO2BxDe86Yq6vIHf5T0cCvmfmccJKIzHtep8bQO9HMBZYCtCBzaXdd1MnxZBPSg==",
+      "version": "1.12.1",
+      "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.12.1.tgz",
+      "integrity": "sha512-biCz/mnkMktImI6hMfMX3H9kOeqsInxWEyCHbSlL8C/2TR1FqfmGxTLRNwYCKsyCyxWLbB8rEqXRVZuyxuLFmA==",
       "license": "MIT",
       "dependencies": {
         "@types/hast": "^3.0.4"
@@ -2285,34 +2236,33 @@
       }
     },
     "node_modules/astro": {
-      "version": "4.12.2",
-      "resolved": "https://registry.npmjs.org/astro/-/astro-4.12.2.tgz",
-      "integrity": "sha512-l6OmqlL+FiuSi9x6F+EGZitteOznq1JffOil7st7cdqeMCTEIym4oagI1a6zp6QekliKWEEZWdplGhgh1k1f7Q==",
+      "version": "4.13.1",
+      "resolved": "https://registry.npmjs.org/astro/-/astro-4.13.1.tgz",
+      "integrity": "sha512-VnMjAc+ykFsIVjgbu9Mt/EA1fMIcPMXbU89h3ATwGOzSIKDsQH72bDgfJkWiwk6u0OE90GeP5EPhAT28Twf9oA==",
       "license": "MIT",
       "dependencies": {
-        "@astrojs/compiler": "^2.9.0",
+        "@astrojs/compiler": "^2.10.0",
         "@astrojs/internal-helpers": "0.4.1",
         "@astrojs/markdown-remark": "5.2.0",
         "@astrojs/telemetry": "3.1.0",
-        "@babel/core": "^7.24.9",
-        "@babel/generator": "^7.24.10",
-        "@babel/parser": "^7.24.8",
-        "@babel/plugin-transform-react-jsx": "^7.24.7",
-        "@babel/traverse": "^7.24.8",
-        "@babel/types": "^7.24.9",
+        "@babel/core": "^7.25.2",
+        "@babel/generator": "^7.25.0",
+        "@babel/parser": "^7.25.3",
+        "@babel/plugin-transform-react-jsx": "^7.25.2",
+        "@babel/traverse": "^7.25.3",
+        "@babel/types": "^7.25.2",
         "@types/babel__core": "^7.20.5",
         "@types/cookie": "^0.6.0",
         "acorn": "^8.12.1",
         "aria-query": "^5.3.0",
         "axobject-query": "^4.1.0",
         "boxen": "7.1.1",
-        "chokidar": "^3.6.0",
         "ci-info": "^4.0.0",
         "clsx": "^2.1.1",
         "common-ancestor-path": "^1.0.1",
         "cookie": "^0.6.0",
         "cssesc": "^3.0.0",
-        "debug": "^4.3.5",
+        "debug": "^4.3.6",
         "deterministic-object-hash": "^2.0.2",
         "devalue": "^5.0.0",
         "diff": "^5.2.0",
@@ -2330,7 +2280,7 @@
         "http-cache-semantics": "^4.1.1",
         "js-yaml": "^4.1.0",
         "kleur": "^4.1.5",
-        "magic-string": "^0.30.10",
+        "magic-string": "^0.30.11",
         "mrmime": "^2.0.0",
         "ora": "^8.0.1",
         "p-limit": "^6.1.0",
@@ -2339,19 +2289,19 @@
         "preferred-pm": "^4.0.0",
         "prompts": "^2.4.2",
         "rehype": "^13.0.1",
-        "semver": "^7.6.2",
-        "shiki": "^1.10.3",
+        "semver": "^7.6.3",
+        "shiki": "^1.12.0",
         "string-width": "^7.2.0",
         "strip-ansi": "^7.1.0",
         "tsconfck": "^3.1.1",
         "unist-util-visit": "^5.0.0",
         "vfile": "^6.0.2",
-        "vite": "^5.3.4",
+        "vite": "^5.3.5",
         "vitefu": "^0.2.5",
         "which-pm": "^3.0.0",
         "yargs-parser": "^21.1.1",
         "zod": "^3.23.8",
-        "zod-to-json-schema": "^3.23.1"
+        "zod-to-json-schema": "^3.23.2"
       },
       "bin": {
         "astro": "astro.js"
@@ -2958,9 +2908,9 @@
       }
     },
     "node_modules/debug": {
-      "version": "4.3.5",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
-      "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
+      "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
       "license": "MIT",
       "dependencies": {
         "ms": "2.1.2"
@@ -4270,12 +4220,12 @@
       }
     },
     "node_modules/magic-string": {
-      "version": "0.30.10",
-      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz",
-      "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==",
+      "version": "0.30.11",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
+      "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
       "license": "MIT",
       "dependencies": {
-        "@jridgewell/sourcemap-codec": "^1.4.15"
+        "@jridgewell/sourcemap-codec": "^1.5.0"
       }
     },
     "node_modules/make-error": {
@@ -4295,9 +4245,10 @@
       }
     },
     "node_modules/marked": {
-      "version": "12.0.1",
-      "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.1.tgz",
-      "integrity": "sha512-Y1/V2yafOcOdWQCX0XpAKXzDakPOpn6U0YLxTJs3cww6VxOzZV1BTOOYWLvH3gX38cq+iLwljHHTnMtlDfg01Q==",
+      "version": "12.0.2",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
+      "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
+      "license": "MIT",
       "bin": {
         "marked": "bin/marked.js"
       },
@@ -6336,12 +6287,12 @@
       }
     },
     "node_modules/shiki": {
-      "version": "1.11.1",
-      "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.11.1.tgz",
-      "integrity": "sha512-VHD3Q0EBXaaa245jqayBe5zQyMQUdXBFjmGr9MpDaDpAKRMYn7Ff00DM5MLk26UyKjnml3yQ0O2HNX7PtYVNFQ==",
+      "version": "1.12.1",
+      "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.12.1.tgz",
+      "integrity": "sha512-nwmjbHKnOYYAe1aaQyEBHvQymJgfm86ZSS7fT8OaPRr4sbAcBNz7PbfAikMEFSDQ6se2j2zobkXvVKcBOm0ysg==",
       "license": "MIT",
       "dependencies": {
-        "@shikijs/core": "1.11.1",
+        "@shikijs/core": "1.12.1",
         "@types/hast": "^3.0.4"
       }
     },
@@ -6616,9 +6567,9 @@
       }
     },
     "node_modules/tailwindcss": {
-      "version": "3.4.6",
-      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz",
-      "integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==",
+      "version": "3.4.7",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz",
+      "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==",
       "license": "MIT",
       "dependencies": {
         "@alloc/quick-lru": "^5.2.0",
@@ -6838,9 +6789,9 @@
       "optional": true
     },
     "node_modules/tsx": {
-      "version": "4.16.2",
-      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.2.tgz",
-      "integrity": "sha512-C1uWweJDgdtX2x600HjaFaucXTilT7tgUZHbOE4+ypskZ1OP8CRCSDkCxG6Vya9EwaFIVagWwpaVAn5wzypaqQ==",
+      "version": "4.16.5",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.5.tgz",
+      "integrity": "sha512-ArsiAQHEW2iGaqZ8fTA1nX0a+lN5mNTyuGRRO6OW3H/Yno1y9/t1f9YOI1Cfoqz63VAthn++ZYcbDP7jPflc+A==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
@@ -7122,9 +7073,9 @@
       }
     },
     "node_modules/vite": {
-      "version": "5.3.4",
-      "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz",
-      "integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==",
+      "version": "5.3.5",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz",
+      "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==",
       "license": "MIT",
       "dependencies": {
         "esbuild": "^0.21.3",
@@ -7363,9 +7314,9 @@
       }
     },
     "node_modules/vscode-languageserver-textdocument": {
-      "version": "1.0.11",
-      "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz",
-      "integrity": "sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==",
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
+      "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
       "license": "MIT"
     },
     "node_modules/vscode-languageserver-types": {
@@ -7716,9 +7667,9 @@
       }
     },
     "node_modules/zod-to-json-schema": {
-      "version": "3.23.1",
-      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.1.tgz",
-      "integrity": "sha512-oT9INvydob1XV0v1d2IadrR74rLtDInLvDFfAa1CG0Pmg/vxATk7I2gSelfj271mbzeM4Da0uuDQE/Nkj3DWNw==",
+      "version": "3.23.2",
+      "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.2.tgz",
+      "integrity": "sha512-uSt90Gzc/tUfyNqxnjlfBs8W6WSGpNBv0rVsNxP/BVSMHMKGdthPYff4xtCHYloJGM0CFxFsb3NbC0eqPhfImw==",
       "license": "ISC",
       "peerDependencies": {
         "zod": "^3.23.3"
diff --git a/package.json b/package.json
index d3dbd83..58ef62a 100644
--- a/package.json
+++ b/package.json
@@ -1,33 +1,32 @@
 {
   "name": "gallery-badmanners-xyz",
   "type": "module",
-  "version": "1.6.0",
+  "version": "1.6.1",
   "scripts": {
     "dev": "astro dev",
     "start": "astro dev",
-    "build": "npm run check && astro build",
+    "build": "astro check && astro build",
     "preview": "astro preview",
     "sync": "astro sync",
-    "check": "astro check --minimumSeverity warning",
+    "check": "astro check",
     "astro": "astro",
     "prettier": "prettier --write .",
-    "deploy-lftp": "dotenv tsx scripts/deploy-lftp.ts --",
     "export-story": "tsx scripts/export-story.ts"
   },
   "dependencies": {
-    "@astrojs/check": "^0.8.2",
+    "@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.12.2",
+    "astro": "^4.13.1",
     "astro-pagefind": "^1.6.0",
     "github-slugger": "^2.0.0",
-    "marked": "^12.0.1",
+    "marked": "^12.0.2",
     "pagefind": "^1.1.0",
     "reading-time": "^1.5.0",
     "sanitize-html": "^2.13.0",
-    "tailwindcss": "^3.4.6",
+    "tailwindcss": "^3.4.7",
     "tiny-decode": "^0.1.3",
     "typescript": "^5.5.4"
   },
@@ -39,6 +38,6 @@
     "prettier": "^3.3.3",
     "prettier-plugin-astro": "^0.14.1",
     "prettier-plugin-tailwindcss": "^0.6.5",
-    "tsx": "^4.16.2"
+    "tsx": "^4.16.5"
   }
 }
diff --git a/src/pages/licenses.txt.ts b/public/licenses.txt
similarity index 53%
rename from src/pages/licenses.txt.ts
rename to public/licenses.txt
index 10010f1..3bf892e 100644
--- a/src/pages/licenses.txt.ts
+++ b/public/licenses.txt
@@ -1,19 +1,11 @@
-import type { APIRoute } from "astro";
+The source code of this website is licensed under the MIT License: https://opensource.org/license/mit
+
+The stories and games hosted on the website are copyrighted by me and licensed under CC-BY-NC-ND-4.0: https://creativecommons.org/licenses/by-nc-nd/4.0/
 
-const licenses = `
 The briefcase logo and any unattributed characters are copyrighted and trademarked by me.
 
-The source code of this website is licensed under the MIT License. The content hosted on it (i.e. stories, games, and respective thumbnails/images) is copyrighted by me and distributed under the CC BY-NC-ND 4.0 license.
+The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1: https://opensource.org/license/ofl-1-1
 
-The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1.
-
-The generic SVG icons were created by Font Awesome and are distributed under the CC-BY-4.0 license.
+The generic SVG icons were created by Font Awesome and are distributed under CC-BY-4.0: https://creativecommons.org/licenses/by/4.0/
 
 All third-party trademarks and attributed characters belong to their respective owners, and I'm not affiliated with any of them.
-`.trim();
-
-const headers = { "Content-Type": "text/plain; charset=utf-8" };
-
-export const GET: APIRoute = () => {
-  return new Response(licenses, { headers });
-};
diff --git a/scripts/deploy-lftp.ts b/scripts/deploy-lftp.ts
deleted file mode 100644
index d418de3..0000000
--- a/scripts/deploy-lftp.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { spawn } from "node:child_process";
-import { program, Option } from "commander";
-
-interface DeployLftpOptions {
-  host: string;
-  user: string;
-  password: string;
-  targetFolder: string;
-  sourceFolder: string;
-}
-
-async function deployLftp({ host, user, password, targetFolder, sourceFolder }: DeployLftpOptions) {
-  const process = spawn(
-    "lftp",
-    [
-      "-c",
-      [
-        `open -u ${user},${password} ${host}`,
-        `mirror --reverse --include-glob assets/* --delete --only-missing --no-perms --verbose ${sourceFolder} ${targetFolder}`,
-        `mirror --reverse --exclude-glob assets/* --delete                --no-perms --verbose ${sourceFolder} ${targetFolder}`,
-        `bye`,
-      ].join("\n"),
-    ],
-    {
-      stdio: "inherit",
-    },
-  );
-  await new Promise<void>((resolve, reject) => {
-    process.on("close", (code) => {
-      if (code === 0) {
-        resolve();
-      } else {
-        reject(`deploy-lftp failed with code ${code}`);
-      }
-    });
-  });
-}
-
-await program
-  .name("deploy-lftp")
-  .description("Deploy files to remote server with LFTP")
-  .addOption(
-    new Option("-h, --host <hostname>", "Hostname of the LFTP (i.e. WebDav, SCP, SFTP...) remote.").env(
-      "DEPLOY_LFTP_HOST",
-    ),
-  )
-  .addOption(new Option("-u, --user <username>", "Username portion of the LFTP credentials").env("DEPLOY_LFTP_USER"))
-  .addOption(
-    new Option("-p, --password <pass>", "Password portion of the LFTP credentials").env("DEPLOY_LFTP_PASSWORD"),
-  )
-  .addOption(
-    new Option("-t, --target-folder <remoteDir>", "Folder to mirror files to in the LFTP remote").env(
-      "DEPLOY_LFTP_TARGETFOLDER",
-    ),
-  )
-  .addOption(
-    new Option("-s, --source-folder <localDir>", "Folder to read files from in the local machine")
-      .env("DEPLOY_LFTP_SOURCEFOLDER")
-      .default("dist/"),
-  )
-  .action(deployLftp)
-  .parseAsync();
diff --git a/scripts/export-story.ts b/scripts/export-story.ts
index 8108a0a..178ce38 100644
--- a/scripts/export-story.ts
+++ b/scripts/export-story.ts
@@ -1,4 +1,4 @@
-import { type ChildProcess, exec, execSync } from "node:child_process";
+import { type ChildProcess, exec, spawnSync } from "node:child_process";
 import { readdir, mkdir, mkdtemp, writeFile, readFile, copyFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join as pathJoin, normalize } from "node:path";
@@ -150,7 +150,13 @@ async function exportStory(slug: string, options: { outputDir: string }) {
   await writeFile(pathJoin(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*"));
   const tempDir = await mkdtemp(pathJoin(tmpdir(), "export-story-"));
   await writeFile(pathJoin(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n"));
-  execSync(`libreoffice --convert-to "rtf:Rich Text Format" --outdir ${tempDir} ${pathJoin(tempDir, "temp.txt")}`);
+  spawnSync("libreoffice", [
+    "--convert-to",
+    "rtf:Rich Text Format",
+    "--outdir",
+    tempDir,
+    pathJoin(tempDir, "temp.txt"),
+  ]);
   const rtfText = await readFile(pathJoin(tempDir, "temp.rtf"), "utf-8");
   const rtfStyles = getRTFStyles(rtfText);
   await writeFile(
@@ -158,7 +164,6 @@ async function exportStory(slug: string, options: { outputDir: string }) {
     rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]),
   );
   console.log("Success!");
-  process.exit(0);
 }
 
 await program
diff --git a/src/assets/thumbnails/bm_20_playing_it_safe.png b/src/assets/thumbnails/bm_20_playing_it_safe.png
index 1dc1384..3f075b1 100755
Binary files a/src/assets/thumbnails/bm_20_playing_it_safe.png and b/src/assets/thumbnails/bm_20_playing_it_safe.png differ
diff --git a/src/components/AgeRestrictedModal.astro b/src/components/AgeRestrictedModal.astro
index 764f2c1..7edb7af 100644
--- a/src/components/AgeRestrictedModal.astro
+++ b/src/components/AgeRestrictedModal.astro
@@ -54,11 +54,13 @@
   (function () {
     if (localStorage.getItem("ageVerified") !== "true") {
       document.body.appendChild(
-        (document.getElementById("template-modal-age-restricted") as HTMLTemplateElement).content.cloneNode(true),
+        document
+          .querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")!
+          .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]")!;
+      const modal = document.querySelector<HTMLElementTagNameMap["div"]>("body > div#modal-age-restricted")!;
+      const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!;
+      const acceptButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!;
       function onRejectButtonClick(e: MouseEvent) {
         e.preventDefault();
         location.href = "about:blank";
diff --git a/src/components/Authors.astro b/src/components/Authors.astro
index 7e74761..89a156c 100644
--- a/src/components/Authors.astro
+++ b/src/components/Authors.astro
@@ -1,5 +1,5 @@
 ---
-import { type Lang } from "../content/config";
+import type { Lang } from "../content/config";
 import { t } from "../i18n";
 
 type Props = {
@@ -12,4 +12,12 @@ const authors = Astro.slots.has("default")
   : [];
 ---
 
-{authors.length ? <p id="authors" set:html={t(lang, "story/authors", authors)} /> : null}
+{
+  authors.length ? (
+    <p
+      id="authors"
+      aria-label={t(lang, "story/authors_aria_label", authors)}
+      set:html={t(lang, "story/authors", authors)}
+    />
+  ) : null
+}
diff --git a/src/components/Commissioners.astro b/src/components/Commissioners.astro
index bd4400e..cb3f5f7 100644
--- a/src/components/Commissioners.astro
+++ b/src/components/Commissioners.astro
@@ -1,5 +1,5 @@
 ---
-import { type Lang } from "../content/config";
+import type { Lang } from "../content/config";
 import { t } from "../i18n";
 
 type Props = {
@@ -12,4 +12,12 @@ const commissioners = Astro.slots.has("default")
   : [];
 ---
 
-{commissioners.length ? <p id="commissioners" set:html={t(lang, "story/commissioned_by", commissioners)} /> : null}
+{
+  commissioners.length ? (
+    <p
+      id="commissioners"
+      aria-label={t(lang, "story/commissioners_aria_label", commissioners)}
+      set:html={t(lang, "story/commissioned_by", commissioners)}
+    />
+  ) : null
+}
diff --git a/src/components/CopyrightedCharacters.astro b/src/components/CopyrightedCharacters.astro
index e4f2936..94a5ff5 100644
--- a/src/components/CopyrightedCharacters.astro
+++ b/src/components/CopyrightedCharacters.astro
@@ -1,6 +1,6 @@
 ---
-import { type CollectionEntry } from "astro:content";
-import { type Lang } from "../content/config";
+import type { CollectionEntry } from "astro:content";
+import type { Lang } from "../content/config";
 import { t } from "../i18n";
 import UserComponent from "./UserComponent.astro";
 import CopyrightedCharactersItem from "./CopyrightedCharactersItem.astro";
@@ -15,7 +15,7 @@ const { copyrightedCharacters, lang } = Astro.props;
 
 {
   copyrightedCharacters ? (
-    <section id="copyrighted-characters">
+    <section id="copyrighted-characters" aria-label={t(lang, "characters/copyrighted_characters_aria_label")}>
       <ul>
         {copyrightedCharacters.map(([owner, characterList]) => (
           <CopyrightedCharactersItem
diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro
index 3abd902..1bf3f22 100644
--- a/src/components/DarkModeScript.astro
+++ b/src/components/DarkModeScript.astro
@@ -6,12 +6,12 @@
 
 <script>
   (function () {
-    var colorScheme = localStorage.getItem("colorScheme");
+    let colorScheme = localStorage.getItem("colorScheme");
     if (colorScheme == null || colorScheme === "auto") {
       colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
     }
-    document.querySelectorAll("button[data-dark-mode]").forEach(function (button) {
-      button.addEventListener("click", function (e) {
+    document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
+      button.addEventListener("click", (e) => {
         e.preventDefault();
         if (colorScheme === "dark") {
           colorScheme = "light";
diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro
index a9eff55..19fb3c2 100644
--- a/src/components/MastodonComments.astro
+++ b/src/components/MastodonComments.astro
@@ -1,37 +1,47 @@
 ---
+import type { Lang } from "../content/config";
+
 type Props = {
-  instance?: string;
-  user?: string;
-  postId?: string;
+  lang: Lang;
+  link: string;
+  instance: string;
+  user: string;
+  postId: string;
 };
 
-const { instance, user, postId } = Astro.props;
+const { link, instance, user, postId } = Astro.props;
 ---
 
 <section
   id="comment-section"
-  class="hidden px-2 font-serif"
+  class="px-2 font-serif"
   aria-describedby="title-comment-section"
-  data-instance={instance || ""}
-  data-user={user || ""}
-  data-post-id={postId || ""}
+  data-link={link}
+  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>
+    <p class="my-1">
+      <a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>
+    </p>
   </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">
+<template id="template-button">
+  <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"
+  >
+    <span>Click to load comments</span>
+  </button>
+</template>
+
+<template id="template-button-loading">
+  <svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden>
     <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
     <path
       class="opacity-100"
@@ -45,12 +55,16 @@ const { instance, user, postId } = Astro.props;
 <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">
+      <a data-author class="text-link flex items-center text-lg hover:underline focus:underline" target="_blank">
         <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
+        data-post-link
+        class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"
+        target="_blank"
+      >
+        <span class="mr-1" data-publish-date aria-label="Publish date"></span>
       </a>
     </div>
     <div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
@@ -72,11 +86,18 @@ const { instance, user, postId } = Astro.props;
         </svg>
       </div>
     </div>
-    <div data-comment-thread class="-mb-2"></div>
+    <div data-comment-thread class="-mb-2" aria-hidden></div>
   </div>
 </template>
 
 <script>
+  interface Post {
+    link: string;
+    instance: string;
+    user: string;
+    postId: string;
+  }
+
   interface Emoji {
     shortcode: string;
     url: string;
@@ -104,115 +125,131 @@ const { instance, user, postId } = Astro.props;
     emojis: Emoji[];
   }
 
+  interface ApiResponse {
+    ancestors: Comment[];
+    descendants: Comment[];
+  }
+
   (function () {
-    const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) =>
-      emojis.reduce(
+    function replaceEmojis(text: string, emojis: Emoji[]) {
+      return emojis.reduce(
         (acc, emoji) =>
           acc.replaceAll(
             `:${emoji.shortcode}:`,
-            `<img class="${imgClass}" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
+            `<img class="inline mx-[1px] w-5" 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) => {
+    async function renderComments(section: Element, post: Post) {
+      const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
+      try {
+        const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
+        if (!response.ok) {
+          throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
+        }
+        const data: ApiResponse = await response.json();
+
+        const commentsList: HTMLElement[] = [];
+        const commentMap: Record<string, number> = {};
+        const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
+          "template#template-comment-box",
+        )!;
+        data.descendants.forEach((comment) => {
+          const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
+
+          const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
+          commentBoxAuthor.href = comment.account.url;
+          const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
+          avatar.src = comment.account.avatar;
+          avatar.alt = `Profile picture of ${comment.account.username}`;
+          const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
+          displayName.innerHTML = replaceEmojis(comment.account.display_name, comment.account.emojis);
+
+          const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
+          commentBoxPostLink.href = comment.url;
+          const publishDate =
+            commentBoxPostLink.querySelector<HTMLElementTagNameMap["span"]>("span[data-publish-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<HTMLElementTagNameMap["div"]>("div[data-content]")!;
+          commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis);
+
+          const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
+          commentBoxFavorites.innerText = comment.favourites_count.toString();
+
+          const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
+          commentBoxReblogs.innerText = comment.reblogs_count.toString();
+
+          if (comment.in_reply_to_id === post.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;
+            const parentThreadDiv =
+              commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
+            parentThreadDiv.removeAttribute("aria-hidden");
+            parentThreadDiv.setAttribute("aria-label", "Replies");
+            parentThreadDiv.appendChild(commentBox);
+          }
+        });
+        if (commentsList.length === 0) {
+          commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="${post.link}" target="_blank">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="${post.link}" target="_blank">by replying on Mastodon</a>.</p>`;
+          commentsDiv.append(...commentsList);
+        }
+      } catch (e) {
+        commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`;
+        console.error("Fetch Mastodon comments error", e);
+      }
+    }
+
+    function initCommentSection() {
+      const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-section");
+      if (!commentSection) {
+        return;
+      }
+      const post = {
+        link: commentSection.dataset.link,
+        instance: commentSection.dataset.instance,
+        user: commentSection.dataset.user,
+        postId: commentSection.dataset.postId,
+      };
+      if (!post.link || !post.instance || !post.user || !post.postId) {
+        return;
+      }
+      const loadCommentsButton = document
+        .querySelector<HTMLElementTagNameMap["template"]>("template#template-button")!
+        .content.cloneNode(true) as HTMLButtonElement;
+      commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!.replaceChildren(loadCommentsButton);
+      loadCommentsButton.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),
+          document
+            .querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")!
+            .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();
-
-            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]")!;
-              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();
+        renderComments(commentSection, post as Post);
       });
     }
+
+    initCommentSection();
   })();
 </script>
diff --git a/src/components/Requesters.astro b/src/components/Requesters.astro
index 4725719..245af9b 100644
--- a/src/components/Requesters.astro
+++ b/src/components/Requesters.astro
@@ -1,5 +1,5 @@
 ---
-import { type Lang } from "../content/config";
+import type { Lang } from "../content/config";
 import { t } from "../i18n";
 
 type Props = {
@@ -12,4 +12,12 @@ const requesters = Astro.slots.has("default")
   : [];
 ---
 
-{requesters.length ? <p id="requesters" set:html={t(lang, "story/requested_by", requesters)} /> : null}
+{
+  requesters.length ? (
+    <p
+      id="requesters"
+      aria-label={t(lang, "story/requesters_aria_label", requesters)}
+      set:html={t(lang, "story/requested_by", requesters)}
+    />
+  ) : null
+}
diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro
index 18ba37e..cacc42a 100644
--- a/src/components/UserComponent.astro
+++ b/src/components/UserComponent.astro
@@ -1,6 +1,6 @@
 ---
-import { type CollectionEntry } from "astro:content";
-import { type Lang } from "../content/config";
+import type { CollectionEntry } from "astro:content";
+import type { Lang } from "../content/config";
 import { getUsernameForLang } from "../utils/get_username_for_lang";
 
 type Props = {
@@ -12,12 +12,7 @@ let { user, lang } = Astro.props;
 const username = getUsernameForLang(user, lang);
 let link: string | null = null;
 if (user.data.preferredLink) {
-  const preferredLink = user.data.links[user.data.preferredLink]!;
-  if (typeof preferredLink === "string") {
-    link = preferredLink;
-  } else {
-    link = preferredLink[0];
-  }
+  link = user.data.links[user.data.preferredLink]!.link;
 }
 ---
 
diff --git a/src/content/LICENSE b/src/content/LICENSE
deleted file mode 120000
index 0b24ab4..0000000
--- a/src/content/LICENSE
+++ /dev/null
@@ -1 +0,0 @@
-../assets/LICENSE
\ No newline at end of file
diff --git a/src/content/config.ts b/src/content/config.ts
index 2ff83af..4a8ba48 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -2,89 +2,214 @@ import { defineCollection, reference, z } from "astro:content";
 
 // Constants
 
-export const WEBSITE_LIST = [
-  "website",
-  "eka",
-  "furaffinity",
-  "weasyl",
-  "inkbunny",
-  "sofurry",
-  "mastodon",
-  "twitter",
-  "bluesky",
-  "itaku",
-] as const;
-export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const;
-export const DEFAULT_LANG = "eng";
-export const DEFAULT_AUTHOR_ID = "bad-manners";
+export const DEFAULT_LANG = "en";
 export const ANONYMOUS_USER_ID = "anonymous";
 
-// Validators
-
-const ekaPostUrlRegex = /^(?:https:\/\/)(?:www\.)?aryion\.com\/g4\/view\/([1-9]\d*)\/?$/;
-const furaffinityPostUrlRegex = /^(?:https:\/\/)(?:www\.)?furaffinity\.net\/view\/([1-9]\d*)\/?$/;
-const weasylPostUrlRegex =
-  /^(?:https:\/\/)(?:www\.)?weasyl\.com\/~([a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/([1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/;
-const inkbunnyPostUrlRegex = /^(?:https:\/\/)(?:www\.)?inkbunny\.net\/s\/([1-9]\d*)\/?$/;
-const sofurryPostUrlRegex = /^(?:https:\/\/)www\.sofurry\.com\/view\/([1-9]\d*)\/?$/;
-const mastodonPostUrlRegex = /^(?:https:\/\/)((?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@([a-zA-Z][a-zA-Z0-9_-]+)\/([1-9]\d*)\/?$/;
-
-const refineAuthors = [
-  (value: { id: any } | any[]) => "id" in value || value.length > 0,
-  `"authors" cannot be empty`,
-] as const;
-const refineCopyrightedCharacters = [
-  (value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1,
-  `"copyrightedCharacters" cannot mix empty catch-all key with other keys`,
-] as const;
-
 // Transformers
 
-const trimText = (text: string) => text.trim();
-const adjustDateForUTCOffset = (date: Date) =>
-  new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
-const parseMastodonPostUrl = (url: string, ctx: z.RefinementCtx) => {
-  const match = mastodonPostUrlRegex.exec(url);
-  if (!match) {
-    ctx.addIssue({
-      code: z.ZodIssueCode.custom,
-      message: `"mastodon" post contains an invalid URL`,
-    });
-    return z.NEVER;
-  }
-  return {
-    instance: match[1]!,
-    user: match[2]!,
-    postId: match[3]!,
+function parseRegex<R extends { [key: string]: string }>(regex: RegExp) {
+  return (url: string, ctx: z.RefinementCtx) => {
+    const match = regex.exec(url);
+    if (!match?.groups) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: `"${ctx.path}" did not match regex`,
+      });
+      return z.NEVER;
+    }
+    return match.groups as R;
   };
-};
+}
+
+// Schema definitions
+
+/** Record of website links for a user.
+ *
+ * For each entry, you can enter a URL for the value or - for any key apart
+ * from `website` - a pre-parsed object containing the link and username.
+ */
+const websiteLinks = z.object({
+  website: z
+    .string()
+    .url()
+    .transform((link) => {
+      link;
+    }),
+  eka: z.object({ link: z.string().url(), username: z.string() }).or(
+    z.string().transform((link, ctx) => {
+      const { username } = parseRegex<{ username: string }>(
+        /^(?:https?:\/\/)?(?:www\.)?aryion\.com\/g4\/user\/(?<username>[^\/]+)\/?$/,
+      )(link, ctx);
+      return { link, username };
+    }),
+  ),
+  furaffinity: z.object({ link: z.string().url(), username: z.string() }).or(
+    z.string().transform((link, ctx) => {
+      const { username } = parseRegex<{ username: string }>(
+        /^(?:https?:\/\/)?(?:www\.)?furaffinity\.net\/user\/(?<username>[^\/]+)\/?$/,
+      )(link, ctx);
+      return { link, username };
+    }),
+  ),
+  weasyl: z.object({ link: z.string().url(), username: z.string() }).or(
+    z.string().transform((link, ctx) => {
+      const { username } = parseRegex<{ username: string }>(
+        /^(?:https?:\/\/)?(?:www\.)?weasyl\.com\/\~(?<username>[^\/]+)\/?$/,
+      )(link, ctx);
+      return { link, username };
+    }),
+  ),
+  inkbunny: z.object({ link: z.string().url(), username: z.string() }).or(
+    z.string().transform((link, ctx) => {
+      const { username } = parseRegex<{ username: string }>(
+        /^(?:https?:\/\/)?(?:www\.)?inkbunny\.net\/(?<username>[^\/]+)\/?$/,
+      )(link, ctx);
+      return { link, username };
+    }),
+  ),
+  sofurry: z.object({ link: z.string().url(), username: z.string() }).or(
+    z.string().transform((link, ctx) => {
+      const { username } = parseRegex<{ username: string }>(/^(?:https?:\/\/)?(?<username>[^\.]+).sofurry.com\/?$/)(
+        link,
+        ctx,
+      );
+      return { link, username };
+    }),
+  ),
+  mastodon: z
+    .object({ link: z.string().url(), username: z.string().regex(/^[^@]+@[^@]+$/) })
+    .or(
+      z.string().transform((link, ctx) => {
+        const { username, instance } = parseRegex<{ username: string; instance: string }>(
+          /^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/(?:@|users\/)(?<username>[a-zA-Z][a-zA-Z0-9_-]+)\/?$/,
+        )(link, ctx);
+        return { link, username: `${username}@${instance}` };
+      }),
+    )
+    .transform(({ link, username }) => {
+      const i = username.indexOf("@");
+      return { link, username, handle: username.slice(0, i), instance: username.slice(i + 1) };
+    }),
+  twitter: z.object({ link: z.string().url(), username: z.string() }).or(
+    z.string().transform((link, ctx) => {
+      const { username } = parseRegex<{ username: string }>(
+        /^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/@?(?<username>[^\/]+)\/?$/,
+      )(link, ctx);
+      return { link, username };
+    }),
+  ),
+  bluesky: z.object({ link: z.string().url(), username: z.string() }).or(
+    z.string().transform((link, ctx) => {
+      const { username } = parseRegex<{ username: string }>(
+        /^(?:https?:\/\/)?bsky\.app\/profile\/(?<username>[^\/]+)\/?$/,
+      )(link, ctx);
+      return { link, username };
+    }),
+  ),
+  itaku: z.object({ link: z.string().url(), username: z.string() }).or(
+    z.string().transform((link, ctx) => {
+      const { username } = parseRegex<{ username: string }>(
+        /^(?:https?:\/\/)?(?:www\.)?itaku\.ee\/profile\/(?<username>[^\/]+)\/?$/,
+      )(link, ctx);
+      return { link, username };
+    }),
+  ),
+});
+/** Available languages. See https://r12a.github.io/app-subtags/ */
+const lang = z.enum(["en", "tok"]).default(DEFAULT_LANG);
+/** Platforms for a game. */
+const platform = z.enum(["web", "windows", "linux", "macos", "android", "ios"]);
+const userList = z
+  .array(reference("users"))
+  .refine((value) => value.length > 0, `user list cannot be empty`)
+  .or(reference("users").transform((user) => [user]));
+/** A record of the format `{"Character name": "user-id"}`.
+ *
+ * An empty character name `""` indicates that all characters are copyrighted
+ * by a certain user.
+ */
+const copyrightedCharacters = z
+  .record(z.string(), reference("users"))
+  .refine(
+    (value) => !("" in value) || Object.keys(value).length == 1,
+    `"copyrightedCharacters" cannot mix empty catch-all key with other keys`,
+  )
+  .default({});
+/** A record of the format `{ en: string, tok?: string, ... }`. */
+const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()));
+/** Common attributes for published content (stories + games). */
+const publishedContent = z.object({
+  // Required parameters
+  title: z.string(),
+  authors: userList,
+  contentWarning: z.string().trim(),
+  // Required parameters, but optional for drafts (isDraft == true)
+  pubDate: z
+    .date()
+    .transform((date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0))
+    .optional(),
+  description: z.string().trim(),
+  tags: z.array(z.string()),
+  // Optional parameters
+  isDraft: z.boolean().default(false),
+  relatedStories: z.array(reference("stories")).default([]),
+  relatedGames: z.array(reference("games")).default([]),
+  lang: lang,
+  copyrightedCharacters: copyrightedCharacters,
+  series: reference("series").optional(),
+  posts: z
+    .object({
+      eka: z.string().transform((link, ctx) => {
+        const { postId } = parseRegex<{ postId: string }>(
+          /^(?:https?:\/\/)(?:www\.)?aryion\.com\/g4\/view\/(?<postId>[1-9]\d*)\/?$/,
+        )(link, ctx);
+        return { link, postId };
+      }),
+      furaffinity: z.string().transform((link, ctx) => {
+        const { postId } = parseRegex<{ postId: string }>(
+          /^(?:https?:\/\/)(?:www\.)?furaffinity\.net\/view\/(?<postId>[1-9]\d*)\/?$/,
+        )(link, ctx);
+        return { link, postId };
+      }),
+      weasyl: z.string().transform((link, ctx) => {
+        const { user, postId } = parseRegex<{ user: string; postId: string }>(
+          /^(?:https?:\/\/)(?:www\.)?weasyl\.com\/~(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/(?<postId>[1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/,
+        )(link, ctx);
+        return { link, user, postId };
+      }),
+      inkbunny: z.string().transform((link, ctx) => {
+        const { postId } = parseRegex<{ postId: string }>(
+          /^(?:https?:\/\/)(?:www\.)?inkbunny\.net\/s\/(?<postId>[1-9]\d*)\/?$/,
+        )(link, ctx);
+        return { link, postId };
+      }),
+      sofurry: z.string().transform((link, ctx) => {
+        const { postId } = parseRegex<{ postId: string }>(
+          /^(?:https?:\/\/)www\.sofurry\.com\/view\/(?<postId>[1-9]\d*)\/?$/,
+        )(link, ctx);
+        return { link, postId };
+      }),
+      bluesky: z.string().transform((link, ctx) => {
+        const { user, postId } = parseRegex<{ user: string; postId: string }>(
+          /^(?:https?:\/\/)bsky\.app\/profile\/(?<user>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/post\/(?<postId>[a-z0-9]+)\/?$/,
+        )(link, ctx);
+        return { link, user, postId };
+      }),
+      mastodon: z.string().transform((link, ctx) => {
+        const { instance, user, postId } = parseRegex<{ instance: string; user: string; postId: string }>(
+          /^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/(?<postId>[1-9]\d*)\/?$/,
+        )(link, ctx);
+        return { link, instance, user, postId };
+      }),
+    })
+    .partial()
+    .default({}),
+});
 
 // Types
 
-const lang = z.enum(["eng", "tok"]).default(DEFAULT_LANG);
-const website = z.enum(WEBSITE_LIST);
-const platform = z.enum(GAME_PLATFORMS);
-const mastodonPost = z
-  .object({
-    instance: z.string(),
-    user: z.string(),
-    postId: z.string(),
-  })
-  .or(z.string().transform(parseMastodonPostUrl));
-const userList = z
-  .array(reference("users"))
-  .or(reference("users").transform((user) => [user]))
-  .refine((value) => value.length > 0, `user list cannot be empty`);
-const authors = userList.default([DEFAULT_AUTHOR_ID]);
-const copyrightedCharacters = z
-  .record(z.string(), reference("users"))
-  .default({})
-  .refine(...refineCopyrightedCharacters);
-// { eng: string, tok?: string, ... }
-const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()));
-
 export type Lang = z.output<typeof lang>;
-export type Website = z.infer<typeof website>;
+export type Website = keyof z.input<typeof websiteLinks>;
 export type GamePlatform = z.infer<typeof platform>;
 export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
 
@@ -93,79 +218,52 @@ export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
 const storiesCollection = defineCollection({
   type: "content",
   schema: ({ image }) =>
-    z.object({
-      // Required
-      title: z.string(),
-      wordCount: z.number().int().optional(),
-      contentWarning: z.string().transform(trimText),
-      description: z.string().transform(trimText),
-      tags: z.array(z.string()),
-      // Optional
-      pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
-      isDraft: z.boolean().default(false),
-      shortTitle: z.string().optional(),
-      authors,
-      summary: z.string().transform(trimText).optional(),
-      thumbnail: image().optional(),
-      thumbnailWidth: z.number().int().optional(),
-      thumbnailHeight: z.number().int().optional(),
-      series: reference("series").optional(),
-      commissioner: userList.optional(),
-      requester: userList.optional(),
-      copyrightedCharacters: copyrightedCharacters,
-      lang,
-      prev: reference("stories").nullish(),
-      next: reference("stories").nullish(),
-      relatedStories: z.array(reference("stories")).default([]),
-      relatedGames: z.array(reference("games")).default([]),
-      posts: z
-        .object({
-          eka: z.string().regex(ekaPostUrlRegex),
-          furaffinity: z.string().regex(furaffinityPostUrlRegex),
-          weasyl: z.string().regex(weasylPostUrlRegex),
-          inkbunny: z.string().regex(inkbunnyPostUrlRegex),
-          sofurry: z.string().regex(sofurryPostUrlRegex),
-          mastodon: mastodonPost,
-        })
-        .partial()
-        .default({}),
-    }),
+    z
+      .object({
+        // Required parameters, but optional for drafts (isDraft == true)
+        wordCount: z.number().int().optional(),
+        thumbnail: image().optional(),
+        // Optional parameters
+        shortTitle: z.string().optional(),
+        commissioner: userList.optional(),
+        requester: userList.optional(),
+        summary: z.string().trim().optional(),
+        thumbnailWidth: z.number().int().optional(),
+        thumbnailHeight: z.number().int().optional(),
+        prev: reference("stories").nullish(),
+        next: reference("stories").nullish(),
+      })
+      .and(publishedContent)
+      .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published story`)
+      .refine(
+        ({ isDraft, contentWarning }) => isDraft || contentWarning,
+        `Missing "contentWarning" for published story`,
+      )
+      .refine(({ isDraft, wordCount }) => isDraft || wordCount, `Missing "wordCount" for published story`)
+      .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published story`)
+      .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published story`)
+      .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published story`),
 });
 
 const gamesCollection = defineCollection({
   type: "content",
   schema: ({ image }) =>
-    z.object({
-      // Required
-      title: z.string(),
-      contentWarning: z.string().transform(trimText),
-      description: z.string().transform(trimText),
-      platforms: z.array(platform).refine((platforms) => platforms.length > 0, `"platforms" cannot be empty`),
-      tags: z.array(z.string()),
-      // Optional
-      pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
-      isDraft: z.boolean().default(false),
-      authors,
-      thumbnail: image().optional(),
-      thumbnailWidth: z.number().int().optional(),
-      thumbnailHeight: z.number().int().optional(),
-      series: reference("series").optional(),
-      copyrightedCharacters: copyrightedCharacters,
-      lang,
-      relatedStories: z.array(reference("stories")).default([]),
-      relatedGames: z.array(reference("games")).default([]),
-      posts: z
-        .object({
-          eka: z.string().regex(ekaPostUrlRegex),
-          furaffinity: z.string().regex(furaffinityPostUrlRegex),
-          weasyl: z.string().regex(weasylPostUrlRegex),
-          inkbunny: z.string().regex(inkbunnyPostUrlRegex),
-          sofurry: z.string().regex(sofurryPostUrlRegex),
-          mastodon: mastodonPost,
-        })
-        .partial()
-        .default({}),
-    }),
+    z
+      .object({
+        // Required parameters, but optional for drafts (isDraft == true)
+        platforms: z.array(platform).default([]),
+        thumbnail: image().optional(),
+        // Optional parameters
+        thumbnailWidth: z.number().int().optional(),
+        thumbnailHeight: z.number().int().optional(),
+      })
+      .and(publishedContent)
+      .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published game`)
+      .refine(({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published game`)
+      .refine(({ isDraft, platforms }) => isDraft || platforms.length, `Missing "platforms" for published game`)
+      .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published game`)
+      .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published game`)
+      .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published game`),
 });
 
 // Data collections
@@ -175,12 +273,11 @@ const usersCollection = defineCollection({
   schema: ({ image }) =>
     z
       .object({
-        // Required
-        name: z.string(),
-        links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
-        // Optional
-        preferredLink: website.nullish(),
-        lang: langRecord.optional(),
+        // Required parameters
+        name: langRecord.or(z.string()),
+        links: websiteLinks.partial(),
+        // Optional parameters
+        preferredLink: websiteLinks.keyof().nullish(),
         avatar: image().optional(),
       })
       .refine(
@@ -195,7 +292,7 @@ const usersCollection = defineCollection({
 const seriesCollection = defineCollection({
   type: "data",
   schema: z.object({
-    // Required
+    // Required parameters
     name: z.string(),
     url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`),
   }),
@@ -204,16 +301,18 @@ const seriesCollection = defineCollection({
 const tagCategoriesCollection = defineCollection({
   type: "data",
   schema: z.object({
-    // Required
+    // Required parameters
     name: z.string(),
     index: z.number().int(),
-    tags: z.array(
-      z.object({
-        name: z.union([z.string(), langRecord]),
-        description: z.string().optional(),
-        related: z.array(z.string()).optional(),
-      }),
-    ),
+    tags: z
+      .array(
+        z.object({
+          name: langRecord.or(z.string()),
+          description: z.string().trim().optional(),
+          related: z.array(z.string()).default([]),
+        }),
+      )
+      .refine((tags) => tags.length, `"tags" cannot be empty`),
   }),
 });
 
diff --git a/src/content/games/crossing-over.md b/src/content/games/crossing-over.md
index 8135d42..8c5da85 100644
--- a/src/content/games/crossing-over.md
+++ b/src/content/games/crossing-over.md
@@ -28,6 +28,7 @@ posts:
   inkbunny: https://inkbunny.net/s/3262911
   sofurry: https://www.sofurry.com/view/2109688
   weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game
+  bluesky: https://bsky.app/profile/badmanners.xyz/post/3kmigrf5q2x24
   mastodon: https://meow.social/@BadManners/112009918919441027
 tags:
   - oral vore
diff --git a/src/content/stories/playing-it-safe.md b/src/content/stories/playing-it-safe.md
index 62005b3..436148f 100644
--- a/src/content/stories/playing-it-safe.md
+++ b/src/content/stories/playing-it-safe.md
@@ -2,7 +2,6 @@
 slug: playing-it-safe
 title: Playing It Safe
 pubDate: 2024-08-08
-isDraft: true
 authors: bad-manners
 wordCount: 9900
 contentWarning: >
diff --git a/src/content/stories/tiny-accident.md b/src/content/stories/tiny-accident.md
index 787da56..cdc5c74 100644
--- a/src/content/stories/tiny-accident.md
+++ b/src/content/stories/tiny-accident.md
@@ -16,6 +16,7 @@ posts:
   inkbunny: https://inkbunny.net/s/3283508
   sofurry: https://www.sofurry.com/view/2118138
   weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident
+  bluesky: https://bsky.app/profile/badmanners.xyz/post/3kok52wijz32c
   mastodon: https://meow.social/@BadManners/112157812554023271
 tags:
   - anthro predator
diff --git a/src/content/tag-categories/1-types-of-vore.yaml b/src/content/tag-categories/1-types-of-vore.yaml
index 165b47d..ce24be4 100644
--- a/src/content/tag-categories/1-types-of-vore.yaml
+++ b/src/content/tag-categories/1-types-of-vore.yaml
@@ -1,7 +1,7 @@
 name: Types of vore
 index: 1
 tags:
-  - name: { eng: oral vore, tok: moku musi kepeken uta }
+  - name: { en: oral vore, tok: moku musi kepeken uta }
     description: Scenarios where prey are consumed by the predator through their mouth.
   - name: anal vore
     description: Scenarios where prey are consumed by the predator through their butt/anus.
diff --git a/src/content/tag-categories/2-body-types.yaml b/src/content/tag-categories/2-body-types.yaml
index 829e7b8..ddadbdf 100644
--- a/src/content/tag-categories/2-body-types.yaml
+++ b/src/content/tag-categories/2-body-types.yaml
@@ -7,7 +7,7 @@ tags:
     description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
   - name: taur predator
     description: Scenarios where at least one of the predators is a multi-legged centaur-like creature, with an animal lower body and anthropomorphic upper body.
-  - name: { eng: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
+  - name: { en: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
     description: Scenarios where the body type of at least one of the predators is left ambiguous.
   - name: human prey
     description: Scenarios where at least one of the prey is a human person.
@@ -15,5 +15,5 @@ tags:
     description: Scenarios where at least one of the prey is an anthropomorphic animal, i.e. generally regarded as a "furry".
   - name: feral prey
     description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
-  - name: { eng: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale }
+  - name: { en: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale }
     description: Scenarios where the body type of at least one of the predators is left ambiguous.
diff --git a/src/content/tag-categories/3-genders.yaml b/src/content/tag-categories/3-genders.yaml
index ffdaa6a..aff336e 100644
--- a/src/content/tag-categories/3-genders.yaml
+++ b/src/content/tag-categories/3-genders.yaml
@@ -13,7 +13,7 @@ tags:
     description: Scenarios where at least one of the predators is a woman and/or female-presenting.
   - name: non-binary predator
     description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not.
-  - name: { eng: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije }
+  - name: { en: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije }
     description: Scenarios where the gender at least one of the predators is left ambiguous.
   - name: male prey
     description: Scenarios where at least one of the prey is a man and/or male-presenting.
@@ -27,5 +27,5 @@ tags:
       - female prey
   - name: non-binary prey
     description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not.
-  - name: { eng: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije }
+  - name: { en: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije }
     description: Scenarios where the gender at least one of the predators is left ambiguous.
diff --git a/src/content/tag-categories/5-willingness.yaml b/src/content/tag-categories/5-willingness.yaml
index c3855cb..326cd50 100644
--- a/src/content/tag-categories/5-willingness.yaml
+++ b/src/content/tag-categories/5-willingness.yaml
@@ -1,7 +1,7 @@
 name: Willingness
 index: 5
 tags:
-  - name: { eng: willing predator, tok: jan pi wawa mute li wile e moku musi }
+  - name: { en: willing predator, tok: jan pi wawa mute li wile e moku musi }
     description: Scenarios where at least one of the predators participates in vore willingly.
   - name: semi-willing predator
     description: Scenarios where the willingness of at least one of the predators might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous.
@@ -13,7 +13,7 @@ tags:
     description: Scenarios where at least one of the prey participates in vore willingly.
   - name: semi-willing prey
     description: Scenarios where the willingness of at least one of the prey might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous.
-  - name: { eng: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi }
+  - name: { en: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi }
     description: Scenarios where at least one of the prey participates in vore unwillingly.
   - name: asleep prey
     description: Scenarios where at least one of the predators participates in vore while asleep.
diff --git a/src/content/tag-categories/6-vore-related-scenarios.yaml b/src/content/tag-categories/6-vore-related-scenarios.yaml
index 5af474e..793ce9b 100644
--- a/src/content/tag-categories/6-vore-related-scenarios.yaml
+++ b/src/content/tag-categories/6-vore-related-scenarios.yaml
@@ -66,4 +66,4 @@ tags:
   - name: soul vore
     description: Scenarios where predators consume a soul instead of their prey's body.
   - name: Vore Day
-    description: Stories created in commemoration of Vore Day, which is celebrated on August 8ᵗʰ, and/or are set in said day.
+    description: Stories created in commemoration of Vore Day, which is celebrated on August 8th, and/or are set in said day.
diff --git a/src/content/tag-categories/9-type-of-content.yaml b/src/content/tag-categories/9-type-of-content.yaml
index b1a76f6..1c12c00 100644
--- a/src/content/tag-categories/9-type-of-content.yaml
+++ b/src/content/tag-categories/9-type-of-content.yaml
@@ -5,7 +5,7 @@ tags:
     description: Stories made by someone else's request, as a gift.
   - name: commission
     description: Stories made as part of a commission to someone else.
-  - name: { eng: flash fiction, tok: lipu lili }
+  - name: { en: flash fiction, tok: lipu lili }
     description: Short-format stories of no more than 2,500 words.
   - name: toki pona
     description: Stories written in toki pona, the language of good.
diff --git a/src/content/users/anonymous.yaml b/src/content/users/anonymous.yaml
index d1c1ea4..3d0ff6f 100644
--- a/src/content/users/anonymous.yaml
+++ b/src/content/users/anonymous.yaml
@@ -1,6 +1,5 @@
-name: Anonymous
-lang:
-  eng: anonymous
+name:
+  en: anonymous
   tok: jan pi nimi ala
 links: {}
 preferredLink: ~
diff --git a/src/content/users/asof-yeun.yaml b/src/content/users/asof-yeun.yaml
index 51f891d..36d7e8c 100644
--- a/src/content/users/asof-yeun.yaml
+++ b/src/content/users/asof-yeun.yaml
@@ -1,7 +1,7 @@
 name: Asof Yeun
 links:
   eka: https://aryion.com/g4/user/asofyeun
-  furaffinity: https://www.furaffinity.net/user/asofyeun
+  furaffinity: https://www.furaffinity.net/user/AsofYeun
   inkbunny: https://inkbunny.net/asofyeun
   sofurry: https://asofyeun.sofurry.com/
   weasyl: https://www.weasyl.com/~asofyeun
diff --git a/src/content/users/bad-manners.yaml b/src/content/users/bad-manners.yaml
index 7da80c8..20f6f6d 100644
--- a/src/content/users/bad-manners.yaml
+++ b/src/content/users/bad-manners.yaml
@@ -1,6 +1,5 @@
-name: Bad Manners
-lang:
-  eng: Bad Manners
+name:
+  en: Bad Manners
   tok: nasin ike Pemene
 avatar: /src/assets/images/logo_bm.png
 links:
@@ -9,8 +8,8 @@ links:
   furaffinity: https://www.furaffinity.net/user/BadManners
   inkbunny: https://inkbunny.net/BadManners
   sofurry:
-    - https://bad-manners.sofurry.com/
-    - Bad Manners
+    link: https://bad-manners.sofurry.com/
+    username: Bad Manners
   weasyl: https://www.weasyl.com/~BadManners
   twitter: https://twitter.com/BadManners__
   mastodon: https://meow.social/@BadManners
diff --git a/src/content/users/hans-woofington.yaml b/src/content/users/hans-woofington.yaml
index dd8021d..24749fc 100644
--- a/src/content/users/hans-woofington.yaml
+++ b/src/content/users/hans-woofington.yaml
@@ -1,6 +1,6 @@
 name: Dr. Hans Woofington
 links:
   furaffinity:
-    - https://www.furaffinity.net/user/hanslewdington/
-    - Hans_Lewdington
+    link: https://www.furaffinity.net/user/hanslewdington/
+    username: Hans_Lewdington
 preferredLink: furaffinity
diff --git a/src/content/users/yolkmonkey.yaml b/src/content/users/yolkmonkey.yaml
index fc08adf..54c2764 100644
--- a/src/content/users/yolkmonkey.yaml
+++ b/src/content/users/yolkmonkey.yaml
@@ -2,6 +2,6 @@ name: YolkMonkey
 links:
   furaffinity: https://furaffinity.net/user/Vampire101
   sofurry:
-    - https://vampire101.sofurry.com/
-    - Vampire101
+    link: https://vampire101.sofurry.com/
+    username: Vampire101
 preferredLink: furaffinity
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 1958420..b63ef6a 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -1,10 +1,11 @@
-import { type GamePlatform, type Lang } from "../content/config";
+import type { GamePlatform, Lang } from "../content/config";
 import { DEFAULT_LANG } from "../content/config";
 export { DEFAULT_LANG } from "../content/config";
 
 const UI_STRINGS = {
+  // Utility functions
   "util/join_names": {
-    eng: (names: string[]) =>
+    en: (names: string[]) =>
       names.length <= 1
         ? names.join("")
         : names.length == 2
@@ -13,10 +14,10 @@ const UI_STRINGS = {
     tok: (names: string[]) => names.join(" en "),
   },
   "util/capitalize": {
-    eng: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""),
+    en: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""),
   },
   "util/enumerate": {
-    eng: (count: number, nounSingular: string, nounPlural: string | undefined) => {
+    en: (count: number, nounSingular: string, nounPlural?: string) => {
       if (count == 0) {
         return `no ${nounPlural ?? nounSingular}`;
       }
@@ -25,146 +26,205 @@ const UI_STRINGS = {
       }
       return `${count} ${nounPlural ?? nounSingular}`;
     },
-    tok: (count: number, nounSingular: string, nounPlural: string | undefined) =>
+    tok: (count: number, nounSingular: string, nounPlural?: string) =>
       `${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`,
   },
-  "export_story/writing": {
-    eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
+  // export-story API functions
+  "export_story/authors": {
+    en: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
     tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`,
   },
   "export_story/request_for": {
-    eng: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`,
+    en: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`,
   },
   "export_story/commissioned_by": {
-    eng: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`,
+    en: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`,
   },
-  "story/return_to_stories": {
-    eng: "Return to stories",
-    tok: "o tawa e lipu ale",
+  // Shared strings for published content (stories + games)
+  "published_content/return_to_series": {
+    en: (seriesName: string) => `Return to ${seriesName}`,
   },
-  "story/return_to_series": {
-    eng: (seriesName: string) => `Return to ${seriesName}`,
-  },
-  "story/go_to_description": {
-    eng: "Go to description",
+  "published_content/go_to_description": {
+    en: "Go to description",
     tok: "o tawa e toki lipu",
   },
-  "story/toggle_dark_mode": {
-    eng: "Toggle dark mode",
+  "published_content/toggle_dark_mode": {
+    en: "Toggle dark mode",
     tok: "o ante e kule lipu",
   },
+  "published_content/cover_art_alt": {
+    en: (title: string) => `Cover art for ${title}`,
+    tok: (_title: string) => `sitelen tawa lipu ni`,
+  },
+  "published_content/publish_date": {
+    en: (date: Date) => date.toISOString().slice(0, 10),
+    tok: (date: Date) => `tenpo suno ${date.toISOString().slice(0, 10)}`,
+  },
+  "published_content/publish_date_aria_label": {
+    en: "Publish date",
+    tok: "tenpo pi pana lipu",
+  },
+  "published_content/publish_date_aria_description": {
+    en: (date: Date) =>
+      date.toLocaleDateString("en-US", {
+        month: "long",
+        day: "numeric",
+        year: "numeric",
+      }),
+    tok: (_date: Date) => "",
+  },
+  "published_content/description": {
+    en: "Description",
+    tok: "toki lipu",
+  },
+  "published_content/to_top": {
+    en: "To top",
+    tok: "tawa sewi",
+  },
+  "published_content/tags": {
+    en: "Tags",
+    tok: "nimi kulupu",
+  },
+  "published_content/copyright_year": {
+    en: (year: string | number) => `© ${year}`,
+    tok: (year: string | number) => `© tenpo pi sike suno ${year}`,
+  },
+  "published_content/licenses": {
+    en: "Licenses",
+    tok: "lipu lawa",
+  },
+  "published_content/draft_warning": {
+    en: "DRAFT VERSION – DO NOT REDISTRIBUTE",
+  },
+  "published_content/related_stories": {
+    en: "Related stories",
+  },
+  "published_content/related_games": {
+    en: "Related games",
+  },
+  // Story page-specific strings
+  "story/return_to_stories": {
+    en: "Return to stories",
+    tok: "o tawa e lipu ale",
+  },
+  "story/title_aria_label": {
+    en: "Story title",
+    tok: "nimi lipu",
+  },
+  "story/authors_aria_label": {
+    en: (authors: any[]) => (authors.length == 1 ? "Author" : "Authors"),
+    tok: (_authors: any[]) => "jan pi pali lipu",
+  },
+  "story/requesters_aria_label": {
+    en: (requesters: any[]) => (requesters.length == 1 ? "Requester" : "Requesters"),
+  },
+  "story/commissioners_aria_label": {
+    en: (commissioners: any[]) => (commissioners.length == 1 ? "Commissioner" : "Commissioners"),
+  },
   "story/warnings": {
-    eng: (wordCount: number | string | undefined, contentWarning: string) =>
+    en: (wordCount: number | string | undefined, contentWarning: string) =>
       wordCount ? `Word count: ${wordCount}. ${contentWarning}` : contentWarning,
     tok: (_wordCount: number | string | undefined, contentWarning: string) => contentWarning,
   },
-  "story/publish_date": {
-    eng: (date: string) => date,
-    tok: (date: string) => `tenpo suno ${date}`,
-  },
-  "story/description": {
-    eng: "Description",
-    tok: "toki lipu",
+  "story/article_aria_label": {
+    en: "Story",
+    tok: "lipu",
   },
   "story/summary": {
-    eng: "Summary",
+    en: "Summary",
     tok: "lipu tawa tenpo lili",
   },
   "story/reveal_summary": {
-    eng: "Click to reveal",
+    en: "Click to reveal",
     tok: "Click to reveal summary in English",
   },
-  "story/to_top": {
-    eng: "To top",
-    tok: "tawa sewi",
-  },
-  "story/tags": {
-    eng: "Tags",
-    tok: "nimi kulupu",
-  },
-  "story/copyright_year": {
-    eng: (year: string | number) => `© ${year}`,
-    tok: (year: string | number) => `© tenpo pi sike suno ${year}`,
-  },
-  "story/licenses": {
-    eng: "Licenses",
-    tok: "lipu lawa",
-  },
   "story/authors": {
-    eng: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].eng(authorsList)}`,
+    en: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].en(authorsList)}`,
     tok: (authorsList: string[]) =>
       authorsList.length > 1
         ? `lipu ni li tan jan ni: ${UI_STRINGS["util/join_names"].tok(authorsList)}`
         : `lipu ni li tan ${authorsList[0]}`,
   },
   "story/commissioned_by": {
-    eng: (commissionersList: string[]) => `Commissioned by ${UI_STRINGS["util/join_names"].eng(commissionersList)}`,
+    en: (commissionersList: string[]) => `Commissioned by ${UI_STRINGS["util/join_names"].en(commissionersList)}`,
   },
   "story/requested_by": {
-    eng: (requestersList: string[]) => `Requested by ${UI_STRINGS["util/join_names"].eng(requestersList)}`,
+    en: (requestersList: string[]) => `Requested by ${UI_STRINGS["util/join_names"].en(requestersList)}`,
   },
-  "story/draft_warning": {
-    eng: "DRAFT VERSION – DO NOT REDISTRIBUTE",
+  // Game page-specific strings
+  "game/return_to_games": {
+    en: "Return to games",
   },
-  "characters/characters_are_copyrighted_by": {
-    eng: (owner: string, charactersList: string[]) =>
-      charactersList.length == 1
-        ? `${charactersList[0]} is © ${owner}`
-        : `${UI_STRINGS["util/join_names"].eng(charactersList)} are © ${owner}`,
-  },
-  "characters/all_characters_are_copyrighted_by": {
-    eng: (owner: string) => `All characters are © ${owner}`,
+  "game/title_aria_label": {
+    en: "Game title",
   },
   "game/platforms": {
-    eng: (platforms: GamePlatform[]) => {
+    en: (platforms: GamePlatform[]) => {
       if (platforms.length == 0) {
         return "";
       }
       const translatedPlatforms = platforms.map((platform) => {
-        const platformLang = UI_STRINGS[`game/platform_${platform}`].eng;
+        const platformLang = UI_STRINGS[`game/platform_${platform}`].en;
         if (!platformLang) {
           throw new Error(`Invalid platform "${platform}"`);
         }
         return platformLang;
       });
-      return `A game for ${UI_STRINGS["util/join_names"].eng(translatedPlatforms)}.`;
+      return `A game for ${UI_STRINGS["util/join_names"].en(translatedPlatforms)}.`;
     },
   },
   "game/platform_web": {
-    eng: "web browsers",
+    en: "web browsers",
   },
   "game/platform_windows": {
-    eng: "Windows",
+    en: "Windows",
   },
   "game/platform_linux": {
-    eng: "Linux",
+    en: "Linux",
   },
   "game/platform_macos": {
-    eng: "macOS",
+    en: "macOS",
   },
   "game/platform_android": {
-    eng: "Android",
+    en: "Android",
   },
   "game/platform_ios": {
-    eng: "iOS",
+    en: "iOS",
   },
   "game/warnings": {
-    eng: (platforms: GamePlatform[], contentWarning: string) =>
-      platforms.length > 0 ? `${UI_STRINGS["game/platforms"].eng(platforms)} ${contentWarning}` : contentWarning,
+    en: (platforms: GamePlatform[], contentWarning: string) =>
+      platforms.length > 0 ? `${UI_STRINGS["game/platforms"].en(platforms)} ${contentWarning}` : contentWarning,
   },
+  "game/article_aria_label": {
+    en: "Game",
+  },
+  // Copyrighted character-related strings
+  "characters/copyrighted_characters_aria_label": {
+    en: "Copyrighted characters",
+  },
+  "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}`,
+  },
+  // Tag-related strings
   "tag/total_works_with_tag": {
-    eng: (tag: string, storiesCount: number, gamesCount: number) => {
+    en: (tag: string, storiesCount: number, gamesCount: number) => {
       const content = [];
       if (storiesCount > 0) {
-        content.push(UI_STRINGS["util/enumerate"].eng(storiesCount, "story", "stories"));
+        content.push(UI_STRINGS["util/enumerate"].en(storiesCount, "story", "stories"));
       }
       if (gamesCount > 0) {
-        content.push(UI_STRINGS["util/enumerate"].eng(gamesCount, "game", "games"));
+        content.push(UI_STRINGS["util/enumerate"].en(gamesCount, "game", "games"));
       }
       if (content.length == 0) {
         return `No works tagged with "${tag}".`;
       }
-      return UI_STRINGS["util/capitalize"].eng(`${UI_STRINGS["util/join_names"].eng(content)} tagged with "${tag}".`);
+      return UI_STRINGS["util/capitalize"].en(`${UI_STRINGS["util/join_names"].en(content)} tagged with "${tag}".`);
     },
   },
 } as const;
@@ -176,6 +236,9 @@ type TranslationEntry<T> = { [DEFAULT_LANG]: T } & {
 type TranslationArgs<K extends TranslationKey> =
   (typeof UI_STRINGS)[K] extends TranslationEntry<infer T> ? (T extends (...args: infer A) => string ? A : []) : never;
 
+/** Translates some text according to the provided language, a translation key,
+ * and optionally any required arguments.
+ */
 export function t<K extends TranslationKey>(lang: Lang, key: K, ...args: TranslationArgs<K>): string {
   if (key in UI_STRINGS) {
     const translation: string | ((...args: TranslationArgs<K>) => string) =
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 6179484..3616a03 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -6,12 +6,13 @@ import AgeRestrictedModal from "../components/AgeRestrictedModal.astro";
 
 type Props = {
   pageTitle?: string;
+  lang?: string;
 };
 
-const { pageTitle } = Astro.props;
+const { pageTitle = "Gallery", lang = "en" } = Astro.props;
 ---
 
-<html lang="en">
+<html lang={lang}>
   <head>
     <meta charset="UTF-8" />
     <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@@ -23,7 +24,7 @@ const { pageTitle } = Astro.props;
     <meta name="theme-color" content="#ffffff" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="generator" content={Astro.generator} />
-    <title>{pageTitle || "Gallery"} | Bad Manners</title>
+    <title>{pageTitle} | Bad Manners</title>
     <link rel="me" href="https://meow.social/@BadManners" />
     <link
       rel="alternate"
diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro
index 4dfc634..933c2bc 100644
--- a/src/layouts/GameLayout.astro
+++ b/src/layouts/GameLayout.astro
@@ -18,8 +18,8 @@ const { props } = Astro;
 const series = props.series && (await getEntry(props.series));
 const authorsList = await getEntries(props.authors);
 const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
-// const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
-// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
+const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
+const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
 const categorizedTags = Object.fromEntries(
   (await getCollection("tag-categories")).flatMap((category) =>
     category.data.tags.map<[string, string | null]>(({ name }) =>
@@ -44,10 +44,10 @@ const thumbnail =
   (await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
 ---
 
-<BaseLayout pageTitle={props.title}>
+<BaseLayout pageTitle={props.title} lang={props.lang}>
   <Fragment slot="head">
     <meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
-    <meta property="og:description" content={props.contentWarning} />
+    <meta property="og:description" content={t(props.lang, "game/warnings", props.platforms, props.contentWarning)} />
     <meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" />
     {
       thumbnail ? (
@@ -55,7 +55,7 @@ const thumbnail =
           <meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
           <meta
             property="og:image:alt"
-            content={`Cover art for ${props.title}`}
+            content={t(props.lang, "published_content/cover_art_alt", props.title)}
             data-pagefind-meta="image_alt[content]"
           />
         </Fragment>
@@ -78,7 +78,9 @@ const thumbnail =
         <a
           href={series ? series.data.url : "/games"}
           class="text-link my-1 h-9 w-9 p-2"
-          aria-label={`Return to ${series ? series.data.name : "games"}`}
+          aria-label={series
+            ? t(props.lang, "published_content/return_to_series", series.data.name)
+            : t(props.lang, "game/return_to_games")}
         >
           <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
             <path
@@ -89,7 +91,7 @@ const thumbnail =
         <a
           href="#description"
           class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
-          aria-label="Go to description"
+          aria-label={t(props.lang, "published_content/go_to_description")}
         >
           <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
             <path
@@ -100,7 +102,7 @@ const thumbnail =
         <button
           data-dark-mode
           class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
-          aria-label="Toggle dark mode"
+          aria-label={t(props.lang, "published_content/toggle_dark_mode")}
         >
           <svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
             <path
@@ -120,7 +122,11 @@ const thumbnail =
       data-pagefind-body={props.isDraft ? undefined : ""}
       data-pagefind-meta="type:game"
     >
-      <h1 id="game-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100">
+      <h1
+        id="game-title"
+        class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"
+        aria-label={t(props.lang, "game/title_aria_label")}
+      >
         {props.title}
       </h1>
       <section
@@ -136,7 +142,7 @@ const thumbnail =
         {
           props.isDraft ? (
             <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
-              {t(props.lang, "story/draft_warning")}
+              {t(props.lang, "published_content/draft_warning")}
             </p>
           ) : null
         }
@@ -155,7 +161,7 @@ const thumbnail =
             <img
               loading="eager"
               src={thumbnail.src}
-              alt={`Cover art for ${props.title}`}
+              alt={t(props.lang, "published_content/cover_art_alt", props.title)}
               width={props.thumbnailWidth}
               height={props.thumbnailHeight}
               class="mx-auto my-5 shadow-lg"
@@ -165,7 +171,7 @@ const thumbnail =
         ) : null
       }
       <hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
-      <article id="game" class="pr-1 font-serif">
+      <article id="game" class="pr-1 font-serif" aria-label={t(props.lang, "game/article_aria_label")}>
         <Prose>
           <slot />
         </Prose>
@@ -177,28 +183,26 @@ const thumbnail =
             id="draft-warning-bottom"
             class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600"
           >
-            {t(props.lang, "story/draft_warning")}
+            {t(props.lang, "published_content/draft_warning")}
           </p>
         ) : props.pubDate ? (
           <p
             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={props.pubDate.toLocaleDateString("en-US", {
-              month: "long",
-              day: "numeric",
-              year: "numeric",
-            })}
+            aria-label={t(props.lang, "published_content/publish_date_aria_label")}
+            aria-description={
+              t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
+            }
             data-pagefind-index-attrs="aria-description"
             data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`}
           >
-            {t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))}
+            {t(props.lang, "published_content/publish_date", props.pubDate)}
           </p>
         ) : null
       }
       <section id="description" class="px-2 font-serif" aria-describedby="title-description">
         <h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
-          {t(props.lang, "story/description")}
+          {t(props.lang, "published_content/description")}
         </h2>
         <Prose>
           <Markdown of={props.description} />
@@ -211,14 +215,50 @@ const thumbnail =
             ><path
               d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"
             ></path></svg
-          ><span>{t(props.lang, "story/to_top")}</span></a
+          ><span>{t(props.lang, "published_content/to_top")}</span></a
         >
       </div>
+      {
+        relatedStories.length > 0 ? (
+          <section id="related" aria-describedby="title-related" class="my-5">
+            <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
+              {t(props.lang, "published_content/related_stories")}
+            </h2>
+            <Prose>
+              <ul>
+                {relatedStories.map((story) => (
+                  <li>
+                    <a href={`/stories/${story.slug}`}>{story.data.title}</a>
+                  </li>
+                ))}
+              </ul>
+            </Prose>
+          </section>
+        ) : null
+      }
+      {
+        relatedGames.length > 0 ? (
+          <section id="related" aria-describedby="title-related" class="my-5">
+            <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
+              {t(props.lang, "published_content/related_games")}
+            </h2>
+            <Prose>
+              <ul>
+                {relatedGames.map((game) => (
+                  <li>
+                    <a href={`/games/${game.slug}`}>{game.data.title}</a>
+                  </li>
+                ))}
+              </ul>
+            </Prose>
+          </section>
+        ) : null
+      }
       {
         tags.length > 0 ? (
           <section id="tags" aria-describedby="title-tags" class="my-5">
             <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
-              Tags
+              {t(props.lang, "published_content/tags")}
             </h2>
             <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
               {tags.map(({ id, name }) => (
@@ -232,16 +272,12 @@ const thumbnail =
           </section>
         ) : null
       }
-      <MastodonComments
-        instance={props.posts.mastodon?.instance}
-        user={props.posts.mastodon?.user}
-        postId={props.posts.mastodon?.postId}
-      />
+      {props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null}
     </main>
     <div class="pt-6 text-center text-xs text-black dark:text-white">
-      <span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
+      <span>{t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
       <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
-        >{t(props.lang, "story/licenses")}</a
+        >{t(props.lang, "published_content/licenses")}</a
       >
     </div>
   </div>
diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro
index b3e50e6..b1ea437 100644
--- a/src/layouts/StoryLayout.astro
+++ b/src/layouts/StoryLayout.astro
@@ -17,21 +17,15 @@ import { formatCopyrightedCharacters } from "../utils/format_copyrighted_charact
 type Props = CollectionEntry<"stories">["data"];
 
 const { props } = Astro;
-let prev = props.prev && (await getEntry(props.prev));
-if (prev && prev.data.isDraft) {
-  prev = undefined;
-}
-let next = props.next && (await getEntry(props.next));
-if (next && next.data.isDraft) {
-  next = undefined;
-}
+const prev = props.prev && (await getEntry(props.prev));
+const next = props.next && (await getEntry(props.next));
 const series = props.series && (await getEntry(props.series));
 const authorsList = await getEntries(props.authors);
 const commissionersList = props.commissioner && (await getEntries(props.commissioner));
 const requestersList = props.requester && (await getEntries(props.requester));
 const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
 const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
-// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
+const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
 const categorizedTags = Object.fromEntries(
   (await getCollection("tag-categories")).flatMap((category) =>
     category.data.tags.map<[string, string | null]>(({ name }) =>
@@ -57,7 +51,7 @@ const thumbnail =
 const wordCount = props.wordCount?.toString();
 ---
 
-<BaseLayout pageTitle={props.title}>
+<BaseLayout pageTitle={props.title} lang={props.lang}>
   <Fragment slot="head">
     <meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
     <meta property="og:description" content={t(props.lang, "story/warnings", wordCount, props.contentWarning)} />
@@ -68,7 +62,7 @@ const wordCount = props.wordCount?.toString();
           <meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
           <meta
             property="og:image:alt"
-            content={`Cover art for ${props.shortTitle || props.title}`}
+            content={t(props.lang, "published_content/cover_art_alt", props.shortTitle || props.title)}
             data-pagefind-meta="image_alt[content]"
           />
         </Fragment>
@@ -92,7 +86,7 @@ const wordCount = props.wordCount?.toString();
           href={series ? series.data.url : "/stories/1"}
           class="text-link my-1 h-9 w-9 p-2"
           aria-label={series
-            ? t(props.lang, "story/return_to_series", series.data.name)
+            ? t(props.lang, "published_content/return_to_series", series.data.name)
             : t(props.lang, "story/return_to_stories")}
         >
           <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
@@ -104,7 +98,7 @@ const wordCount = props.wordCount?.toString();
         <a
           href="#description"
           class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
-          aria-label={t(props.lang, "story/go_to_description")}
+          aria-label={t(props.lang, "published_content/go_to_description")}
         >
           <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
             <path
@@ -115,7 +109,7 @@ const wordCount = props.wordCount?.toString();
         <button
           data-dark-mode
           class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
-          aria-label={t(props.lang, "story/toggle_dark_mode")}
+          aria-label={t(props.lang, "published_content/toggle_dark_mode")}
         >
           <svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
             <path
@@ -136,7 +130,7 @@ const wordCount = props.wordCount?.toString();
       data-pagefind-meta="type:story"
     >
       {
-        prev || next ? (
+        (prev && !prev.data.isDraft) || (next && !next.data.isDraft) ? (
           <div class="print:hidden">
             <div id="story-nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2">
               {prev ? (
@@ -170,7 +164,11 @@ const wordCount = props.wordCount?.toString();
           </div>
         ) : null
       }
-      <h1 id="story-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100">
+      <h1
+        id="story-title"
+        class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"
+        aria-label={t(props.lang, "story/title_aria_label")}
+      >
         {props.title}
       </h1>
       <section
@@ -180,13 +178,6 @@ const wordCount = props.wordCount?.toString();
         <Authors lang={props.lang}>
           {authorsList.map((author) => <UserComponent user={author} lang={props.lang} />)}
         </Authors>
-        {
-          props.isDraft ? (
-            <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
-              {t(props.lang, "story/draft_warning")}
-            </p>
-          ) : null
-        }
         {
           requestersList && (
             <Requesters lang={props.lang}>
@@ -205,6 +196,13 @@ const wordCount = props.wordCount?.toString();
             </Commissioners>
           )
         }
+        {
+          props.isDraft ? (
+            <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
+              {t(props.lang, "published_content/draft_warning")}
+            </p>
+          ) : null
+        }
         <div id="content-warning">
           <p>
             {t(props.lang, "story/warnings", wordCount, props.contentWarning)}
@@ -218,7 +216,7 @@ const wordCount = props.wordCount?.toString();
             <img
               loading="eager"
               src={thumbnail.src}
-              alt={`Cover art for ${props.shortTitle || props.title}`}
+              alt={t(props.lang, "published_content/cover_art_alt", props.shortTitle || props.title)}
               width={props.thumbnailWidth}
               height={props.thumbnailHeight}
               class="mx-auto my-5 shadow-lg"
@@ -227,7 +225,7 @@ const wordCount = props.wordCount?.toString();
         ) : 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="story" class="pr-1 font-serif" aria-label={t(props.lang, "story/article_aria_label")}>
         <Prose>
           <slot />
         </Prose>
@@ -239,28 +237,26 @@ const wordCount = props.wordCount?.toString();
             id="draft-warning-bottom"
             class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600"
           >
-            {t(props.lang, "story/draft_warning")}
+            {t(props.lang, "published_content/draft_warning")}
           </p>
         ) : props.pubDate ? (
           <p
             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={props.pubDate.toLocaleDateString("en-US", {
-              month: "long",
-              day: "numeric",
-              year: "numeric",
-            })}
+            aria-label={t(props.lang, "published_content/publish_date_aria_label")}
+            aria-description={
+              t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
+            }
             data-pagefind-index-attrs="aria-description"
             data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`}
           >
-            {t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))}
+            {t(props.lang, "published_content/publish_date", props.pubDate)}
           </p>
         ) : null
       }
       <section id="description" class="px-2 font-serif" aria-describedby="title-description">
         <h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
-          {t(props.lang, "story/description")}
+          {t(props.lang, "published_content/description")}
         </h2>
         <Prose>
           <Markdown of={props.description} />
@@ -292,7 +288,7 @@ const wordCount = props.wordCount?.toString();
             ><path
               d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"
             ></path></svg
-          ><span>{t(props.lang, "story/to_top")}</span></a
+          ><span>{t(props.lang, "published_content/to_top")}</span></a
         >
       </div>
       {
@@ -335,13 +331,31 @@ const wordCount = props.wordCount?.toString();
         relatedStories.length > 0 ? (
           <section id="related" aria-describedby="title-related" class="my-5">
             <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
-              Related stories
+              {t(props.lang, "published_content/related_stories")}
             </h2>
             <Prose>
               <ul>
-                {relatedStories.map((stories) => (
+                {relatedStories.map((story) => (
                   <li>
-                    <a href={`/stories/${stories.slug}`}>{stories.data.title}</a>
+                    <a href={`/stories/${story.slug}`}>{story.data.title}</a>
+                  </li>
+                ))}
+              </ul>
+            </Prose>
+          </section>
+        ) : null
+      }
+      {
+        relatedGames.length > 0 ? (
+          <section id="related" aria-describedby="title-related" class="my-5">
+            <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
+              {t(props.lang, "published_content/related_games")}
+            </h2>
+            <Prose>
+              <ul>
+                {relatedGames.map((game) => (
+                  <li>
+                    <a href={`/games/${game.slug}`}>{game.data.title}</a>
                   </li>
                 ))}
               </ul>
@@ -353,7 +367,7 @@ const wordCount = props.wordCount?.toString();
         tags.length > 0 ? (
           <section id="tags" aria-describedby="title-tags" class="my-5">
             <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
-              {t(props.lang, "story/tags")}
+              {t(props.lang, "published_content/tags")}
             </h2>
             <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
               {tags.map(({ id, name }) => (
@@ -367,16 +381,12 @@ const wordCount = props.wordCount?.toString();
           </section>
         ) : null
       }
-      <MastodonComments
-        instance={props.posts.mastodon?.instance}
-        user={props.posts.mastodon?.user}
-        postId={props.posts.mastodon?.postId}
-      />
+      {props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null}
     </main>
     <div class="pt-6 text-center text-xs text-black dark:text-white">
-      <span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
+      <span>{t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
       <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
-        >{t(props.lang, "story/licenses")}</a
+        >{t(props.lang, "published_content/licenses")}</a
       >
     </div>
   </div>
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index f912a15..47eea8e 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -1,6 +1,6 @@
 import type { APIRoute, GetStaticPaths } from "astro";
-import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
-import type { Website } from "../../../content/config";
+import { getCollection, type CollectionEntry, getEntries } from "astro:content";
+import type { Lang, Website } from "../../../content/config";
 import { t } from "../../../i18n";
 import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
 import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
@@ -24,72 +24,8 @@ type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: in
 
 function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
   const link = user.data.links[website];
-  if (link) {
-    if (typeof link === "string") {
-      switch (website) {
-        case "website":
-          break;
-        case "eka":
-          const ekaMatch = link.match(/^.*\baryion\.com\/g4\/user\/([^\/]+)\/?$/);
-          if (ekaMatch && ekaMatch[1]) {
-            return ekaMatch[1];
-          }
-          break;
-        case "furaffinity":
-          const faMatch = link.match(/^.*\bfuraffinity\.net\/user\/([^\/]+)\/?$/);
-          if (faMatch && faMatch[1]) {
-            return faMatch[1];
-          }
-          break;
-        case "inkbunny":
-          const ibMatch = link.match(/^.*\binkbunny\.net\/([^\/]+)\/?$/);
-          if (ibMatch && ibMatch[1]) {
-            return ibMatch[1];
-          }
-          break;
-        case "sofurry":
-          const sfMatch = link.match(/^(?:https?:\/\/)?([^\.]+).sofurry.com\b.*$/);
-          if (sfMatch && sfMatch[1]) {
-            return sfMatch[1].replaceAll("-", " ");
-          }
-          break;
-        case "weasyl":
-          const weasylMatch = link.match(/^.*\bweasyl\.com\/\~([^\/]+)\/?$/);
-          if (weasylMatch && weasylMatch[1]) {
-            return weasylMatch[1];
-          }
-          break;
-        case "twitter":
-          const twitterMatch = link.match(/^.*(?:\btwitter\.com|\bx\.com)\/@?([^\/]+)\/?$/);
-          if (twitterMatch && twitterMatch[1]) {
-            return twitterMatch[1];
-          }
-          break;
-        case "mastodon":
-          const mastodonMatch = link.match(/^(?:https?\:\/\/)?([^\/]+)\/(?:@|users\/)([^\/]+)\/?$/);
-          if (mastodonMatch && mastodonMatch[1] && mastodonMatch[2]) {
-            return `${mastodonMatch[2]}@${mastodonMatch[1]}`;
-          }
-          break;
-        case "bluesky":
-          const bskyMatch = link.match(/^.*\bbsky\.app\/profile\/([^\/]+)\/?$/);
-          if (bskyMatch && bskyMatch[1]) {
-            return bskyMatch[1];
-          }
-          break;
-        case "itaku":
-          const itakuMatch = link.match(/^.*\bitaku\.ee\/profile\/([^\/]+)\/?$/);
-          if (itakuMatch && itakuMatch[1]) {
-            return itakuMatch[1];
-          }
-          break;
-        default:
-          let _: never = website;
-          throw new Error(`Unhandled website "${website}"`);
-      }
-    } else {
-      return link[1].replace(/^@/, "");
-    }
+  if (link?.username) {
+    return link.username;
   }
   throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
 }
@@ -99,20 +35,21 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b
   return !preferredLink || preferredLink == website;
 }
 
-function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName): string {
+function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, lang: Lang): string {
+  const { links, preferredLink } = user.data;
   switch (website) {
     case "eka":
-      if ("eka" in user.data.links) {
+      if ("eka" in links) {
         return `:icon${getUsernameForWebsite(user, "eka")}:`;
       }
       break;
     case "furaffinity":
-      if ("furaffinity" in user.data.links) {
+      if ("furaffinity" in links) {
         return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
       }
       break;
     case "weasyl":
-      if ("weasyl" in user.data.links) {
+      if ("weasyl" in links) {
         return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
       } else if (isPreferredWebsite(user, "furaffinity")) {
         return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
@@ -123,7 +60,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
       }
       break;
     case "inkbunny":
-      if ("inkbunny" in user.data.links) {
+      if ("inkbunny" in links) {
         return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
       } else if (isPreferredWebsite(user, "furaffinity")) {
         return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
@@ -134,7 +71,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
       }
       break;
     case "sofurry":
-      if ("sofurry" in user.data.links) {
+      if ("sofurry" in links) {
         return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
       } else if (isPreferredWebsite(user, "furaffinity")) {
         return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
@@ -143,11 +80,12 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
       }
       break;
     default:
-      throw new Error(`Unhandled ExportWebsite "${website}"`);
+      const unknown: never = website;
+      throw new Error(`Unhandled export website "${unknown}"`);
   }
-  if (user.data.preferredLink) {
-    const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
-    return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
+  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)`,
@@ -182,14 +120,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
   const description = Object.fromEntries(
     WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
       const u = (user: CollectionEntry<"users">) =>
-        isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website);
+        isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website, lang);
       const storyDescription = (
         [
           story.data.description,
           `*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`,
           t(
             lang,
-            "export_story/writing",
+            "export_story/authors",
             authorsList.map((author) => u(author)),
           ),
           requestersList &&
@@ -216,12 +154,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
           /\[([^\]]+)\]\((\/[^\)]+)\)/g,
           (_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
         );
-      if (exportFormat === "bbcode") {
-        return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
-      } else if (exportFormat === "markdown") {
-        return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
-      } else {
-        throw new Error(`Unhandled export format "${exportFormat}"`);
+      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}"`);
       }
     }),
   );
@@ -252,13 +192,12 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
     .replaceAll(/\n\n\n+/g, "\n\n")
     .trim();
 
-  const headers = { "Content-Type": "application/json; charset=utf-8" };
   return new Response(
     JSON.stringify({
       story: storyText,
       description,
       thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
     }),
-    { headers },
+    { headers: { "Content-Type": "application/json; charset=utf-8" } },
   );
 };
diff --git a/src/pages/api/healthcheck.ts b/src/pages/api/healthcheck.ts
index cf9fedd..6f2f3c4 100644
--- a/src/pages/api/healthcheck.ts
+++ b/src/pages/api/healthcheck.ts
@@ -1,12 +1,10 @@
 import type { APIRoute } from "astro";
 
-const content = { isAlive: true };
-
-const headers = { "Content-Type": "application/json; charset=utf-8" };
-
 export const GET: APIRoute = () => {
   if (import.meta.env.PROD) {
     return new Response(null, { status: 404 });
   }
-  return new Response(JSON.stringify(content), { headers });
+  return new Response(JSON.stringify({ isAlive: true }), {
+    headers: { "Content-Type": "application/json; charset=utf-8" },
+  });
 };
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index 76dda05..57785f3 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -24,8 +24,7 @@ function toNoonUTCDate(date: Date) {
 const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
   const userName = getUsernameForLang(user, lang);
   if (user.data.preferredLink) {
-    const link = user.data.links[user.data.preferredLink]!;
-    return `<a href="${typeof link === "string" ? link : link[0]}">${userName}</a>`;
+    return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`;
   }
   return userName;
 };
diff --git a/src/pages/games/[...slug].astro b/src/pages/games/[...slug].astro
index 7c12085..4e3c03b 100644
--- a/src/pages/games/[...slug].astro
+++ b/src/pages/games/[...slug].astro
@@ -18,17 +18,6 @@ export const getStaticPaths: GetStaticPaths = async () => {
 };
 
 const game = Astro.props;
-if (!game.data.isDraft) {
-  if (!game.data.pubDate) {
-    throw new Error(`Missing "pubDate" for published game ${game.data.title} ("${game.slug}")`);
-  }
-  if (!game.data.thumbnail) {
-    throw new Error(`Missing "thumbnail" for published game ${game.data.title} ("${game.slug}")`);
-  }
-  if (game.data.tags.length == 0) {
-    throw new Error(`Missing "tags" for published game ${game.data.title} ("${game.slug}")`);
-  }
-}
 const { Content } = await game.render();
 ---
 
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 2d35136..b70bd29 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,5 +1,5 @@
 ---
-import { type CollectionEntry, getCollection, type CollectionKey } from "astro:content";
+import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content";
 import { Image } from "astro:assets";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
 import { t } from "../i18n";
diff --git a/src/pages/stories/[...slug].astro b/src/pages/stories/[...slug].astro
index 39d5ab0..2f58526 100644
--- a/src/pages/stories/[...slug].astro
+++ b/src/pages/stories/[...slug].astro
@@ -20,21 +20,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
 
 const story = Astro.props;
 const readingTime = getReadingTime(story.body);
-if (!story.data.isDraft) {
-  if (!story.data.wordCount) {
-    throw new Error(`Missing "wordCount" for published story ${story.data.title} ("${story.slug}")`);
-  }
-  if (!story.data.pubDate) {
-    throw new Error(`Missing "pubDate" for published story ${story.data.title} ("${story.slug}")`);
-  }
-  if (!story.data.thumbnail) {
-    throw new Error(`Missing "thumbnail" for published story ${story.data.title} ("${story.slug}")`);
-  }
-  if (story.data.tags.length == 0) {
-    throw new Error(`Missing "tags" for published story ${story.data.title} ("${story.slug}")`);
-  }
-}
-if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 135) {
+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}") ` +
       `(expected ~${story.data.wordCount}, found ${readingTime.words})`,
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 32d1e6e..8c4577e 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -10,65 +10,53 @@ interface Tag {
   description?: string;
 }
 
-const [stories, games, tagCategories] = await Promise.all([
+const [stories, games, tagCategories, seriesCollection] = await Promise.all([
   getCollection("stories"),
   getCollection("games"),
   getCollection("tag-categories"),
+  getCollection("series"),
 ]);
-const tagsSet = new Set<string>();
+const uncategorizedTagsSet = new Set<string>();
 const draftOnlyTagsSet = new Set<string>();
-const seriesCollection = await getCollection("series");
-// Add tags from non-drafts to set; then, add tags only from drafts to separate set
-[stories, games]
-  .flat()
-  .sort((a, b) => (a.data.isDraft ? 1 : b.data.isDraft ? -1 : 0))
-  .forEach((value) => {
-    if (value.data.isDraft) {
-      value.data.tags.forEach((tag) => {
-        if (!tagsSet.has(tag)) {
-          draftOnlyTagsSet.add(tag);
-        }
-      });
-    } else {
-      value.data.tags.forEach((tag) => {
-        tagsSet.add(tag);
-      });
-    }
-  });
+[stories, games].flat().forEach(({ data: { isDraft, tags } }) => {
+  if (isDraft) {
+    tags.forEach((tag) => draftOnlyTagsSet.add(tag));
+  } else {
+    tags.forEach((tag) => uncategorizedTagsSet.add(tag));
+  }
+});
+// Tags from published content shouldn't be included in drafts-only set
+uncategorizedTagsSet.forEach((tag) => draftOnlyTagsSet.delete(tag));
 
-const uncategorizedTagsSet = new Set(tagsSet);
+const uniqueSlugs = new Set<string>();
 const categorizedTags = tagCategories
   .sort((a, b) => {
     if (a.data.index == b.data.index) {
-      throw new Error(
-        `Found tag categories with same index value ${a.data.index} ("${a.data.name}", "${b.data.name}")`,
-      );
+      throw new Error(`Found tag categories with same index value ${a.data.index} ("${a.id}", "${b.id}")`);
     }
     return a.data.index - b.data.index;
   })
   .map((category) => {
     const tagList = category.data.tags.map<Tag>(({ name, description }) => {
       description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ");
-      const tag = typeof name === "string" ? name : name["eng"];
-      const id = slug(tag);
-      return { id, name: tag, description };
+      const tag = typeof name === "string" ? name : name.en;
+      return { id: slug(tag), name: tag, description };
     });
-    tagList.forEach(({ name }, index) => {
-      if (index !== tagList.findLastIndex(({ name: otherTag }) => name == otherTag)) {
-        throw new Error(`Duplicated tag "${name}" found in multiple tag-categories lists`);
+    tagList.forEach(({ id, name }) => {
+      if (uniqueSlugs.has(id)) {
+        throw new Error(`Duplicated tag "${name}" found in multiple tag-categories entries`);
       }
+      uniqueSlugs.add(id);
     });
     return {
       name: category.data.name,
-      id: category.id,
+      id: slug(category.data.name),
       tags: tagList.filter(({ name }) => {
         if (draftOnlyTagsSet.has(name)) {
-          console.log(`Omitting draft-only tag "${name}"`);
+          // console.log(`Omitting draft-only tag "${name}"`);
           return false;
         }
-        if (tagsSet.has(name)) {
-          uncategorizedTagsSet.delete(name);
-        }
+        uncategorizedTagsSet.delete(name);
         return true;
       }),
     };
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index dfee5fb..67278cb 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -1,7 +1,7 @@
 ---
 import type { GetStaticPaths } from "astro";
 import { Image } from "astro:assets";
-import { type CollectionEntry, getCollection, type CollectionKey } from "astro:content";
+import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content";
 import { Markdown } from "@astropub/md";
 import { slug } from "github-slugger";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
@@ -45,24 +45,22 @@ export const getStaticPaths: GetStaticPaths = async () => {
   const tagDescriptions = tagCategories.reduce(
     (acc, category) => {
       category.data.tags.forEach(({ name, description, related }) => {
-        if (related) {
-          related = related.filter((relatedTag) => {
-            if (relatedTag == name) {
-              console.warn(`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`);
-              return false;
-            }
-            return true;
-          });
-        }
-        const key = typeof name === "string" ? name : name["eng"];
+        related = related.filter((relatedTag) => {
+          if (relatedTag == name) {
+            console.warn(`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`);
+            return false;
+          }
+          return true;
+        });
+        const key = typeof name === "string" ? name : name.en;
         if (key in acc) {
           throw new Error(`Duplicated tag "${key}" found in multiple tag-categories lists`);
         }
-        acc[key] = { description, related };
+        acc[key] = { description, related: related.length > 0 ? related : undefined };
       });
       return acc;
     },
diff --git a/src/utils/get_username_for_lang.ts b/src/utils/get_username_for_lang.ts
index c422cc1..f2cace8 100644
--- a/src/utils/get_username_for_lang.ts
+++ b/src/utils/get_username_for_lang.ts
@@ -2,14 +2,15 @@ import type { CollectionEntry } from "astro:content";
 import { DEFAULT_LANG, type Lang } from "../content/config";
 
 export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string {
-  if (user.data.lang) {
-    if (user.data.lang[lang]) {
-      return user.data.lang[lang];
+  const { name } = user.data;
+  if (typeof name === "object") {
+    if (name[lang]) {
+      return name[lang];
     }
-    throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`);
+    throw new Error(`No "${lang}" translation for user "${user.id}"`);
   }
   if (lang !== DEFAULT_LANG) {
-    console.warn(`User "${user.data.name}" ("${user.id}") has no "lang" property for a "${lang}" translation`);
+    console.warn(`Name "${name}" for user "${user.id}" isn't translated to "${lang}"; using default`);
   }
-  return user.data.name;
+  return name;
 }
diff --git a/src/utils/is_anonymous_user.ts b/src/utils/is_anonymous_user.ts
index 070adb0..57ed851 100644
--- a/src/utils/is_anonymous_user.ts
+++ b/src/utils/is_anonymous_user.ts
@@ -1,6 +1,6 @@
 import type { CollectionEntry } from "astro:content";
-import { ANONYMOUS_USER_ID as ID } from "../content/config";
+import { ANONYMOUS_USER_ID } from "../content/config";
 
-const ANONYMOUS_USER_ID: CollectionEntry<"users">["id"] = ID;
+const ID: CollectionEntry<"users">["id"] = ANONYMOUS_USER_ID;
 
-export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id == ANONYMOUS_USER_ID;
+export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id === ID;
diff --git a/tsconfig.json b/tsconfig.json
index bcbf8b5..da42df9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,3 +1,4 @@
 {
-  "extends": "astro/tsconfigs/strict"
+  "extends": "astro/tsconfigs/strict",
+  "exclude": ["dist"]
 }