diff --git a/package-lock.json b/package-lock.json
index 6b8721a..5dd9450 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery-badmanners-xyz",
-  "version": "1.5.0",
+  "version": "1.5.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery-badmanners-xyz",
-      "version": "1.5.0",
+      "version": "1.5.1",
       "dependencies": {
         "@astrojs/check": "^0.5.10",
         "@astrojs/rss": "^4.0.5",
@@ -15,7 +15,7 @@
         "@tailwindcss/typography": "^0.5.12",
         "@types/sanitize-html": "^2.11.0",
         "astro": "^4.5.16",
-        "astro-pagefind": "^1.5.0",
+        "astro-pagefind": "^1.6.0",
         "github-slugger": "^2.0.0",
         "marked": "^12.0.1",
         "pagefind": "^1.1.0",
@@ -1800,9 +1800,10 @@
       }
     },
     "node_modules/astro-pagefind": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/astro-pagefind/-/astro-pagefind-1.5.0.tgz",
-      "integrity": "sha512-CN7Afe9qW640U1qliCQMXN259Dl6VPUnl8FneLfKE7STV4HLiif4PbNZejylC6yXYyP6uyNVDHNOfJfBBW5h6A==",
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/astro-pagefind/-/astro-pagefind-1.6.0.tgz",
+      "integrity": "sha512-U/WuE0ktkZkoFJf6yopWO4DjIJ3+wrnopE2L3kUYiyqNTJpqmp13bFLR8gir6B+KzQ5dsXQtJZYTQtKJg1FxIA==",
+      "license": "MIT",
       "dependencies": {
         "@pagefind/default-ui": "^1.0.3",
         "pagefind": "^1.0.3",
diff --git a/package.json b/package.json
index d1096c3..d9647da 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery-badmanners-xyz",
   "type": "module",
-  "version": "1.5.0",
+  "version": "1.5.1",
   "scripts": {
     "dev": "astro dev",
     "start": "astro dev",
@@ -20,7 +20,7 @@
     "@tailwindcss/typography": "^0.5.12",
     "@types/sanitize-html": "^2.11.0",
     "astro": "^4.5.16",
-    "astro-pagefind": "^1.5.0",
+    "astro-pagefind": "^1.6.0",
     "github-slugger": "^2.0.0",
     "marked": "^12.0.1",
     "pagefind": "^1.1.0",
diff --git a/public/.htaccess b/public/.htaccess
index de377bf..41b8d2d 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -1 +1,2 @@
 ErrorDocument 404 /404.html
+RedirectMatch 301 ^/stories(\/(index.html)?)?$ /stories/1/
diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro
index 2744def..fd8fbbc 100644
--- a/src/components/MastodonComments.astro
+++ b/src/components/MastodonComments.astro
@@ -53,7 +53,7 @@ const { instance, user, postId } = Astro.props;
         <span class="mr-1" data-publish-date></span>
       </a>
     </div>
-    <div data-content class="prose-a:text-link prose prose-story my-1 dark:prose-invert prose-img:my-0"></div>
+    <div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
     <div class="ml-1 flex flex-row pb-2 pt-1">
       <div class="flex" aria-label="Favorites">
         <span data-favorites></span>
diff --git a/src/components/Prose.astro b/src/components/Prose.astro
index 91a1cb8..cf16681 100644
--- a/src/components/Prose.astro
+++ b/src/components/Prose.astro
@@ -2,6 +2,6 @@
 
 ---
 
-<div class="prose-a:text-link prose prose-story max-w-none dark:prose-invert">
+<div class="prose-a:text-link prose prose-bm max-w-none dark:prose-invert">
   <slot />
 </div>
diff --git a/src/content/config.ts b/src/content/config.ts
index 3654509..3ea19e6 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -185,10 +185,14 @@ const tagCategoriesCollection = defineCollection({
     name: z.string(),
     index: z.number().int(),
     tags: z.array(
-      z.union([
-        z.string(),
-        z.record(lang, z.string()).refine((tag) => "eng" in tag, `object-formatted tag must have an "eng" key`),
-      ]),
+      z.object({
+        name: z.union([
+          z.string(),
+          z.record(lang, z.string()).refine((tag) => "eng" in tag, `object-formatted tag must have an "eng" key`),
+        ]),
+        description: z.string().optional(),
+        related: z.array(z.string()).optional(),
+      }),
     ),
   }),
 });
diff --git a/src/content/games/crossing-over.md b/src/content/games/crossing-over.md
index 12a55a4..b74eb41 100644
--- a/src/content/games/crossing-over.md
+++ b/src/content/games/crossing-over.md
@@ -35,9 +35,9 @@ platforms:
 posts:
   eka: https://aryion.com/g4/view/986297
   furaffinity: https://www.furaffinity.net/view/55712675/
-  weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game
   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
   mastodon: https://meow.social/@BadManners/112009918919441027
 tags:
   - oral vore
diff --git a/src/content/stories/rose-s-binge.md b/src/content/stories/rose-s-binge.md
index f2559c3..689649d 100644
--- a/src/content/stories/rose-s-binge.md
+++ b/src/content/stories/rose-s-binge.md
@@ -10,6 +10,13 @@ description: |
   A seemingly insatiable plushie makes short work of an entire town, and the last few people left will try their best to escape from their binge.
 
   This is a commission for Dee! As a disclaimer, all characters, implied or otherwise, are adults.
+posts:
+  eka: https://aryion.com/g4/view/1030875
+  furaffinity: https://www.furaffinity.net/view/57459926/
+  inkbunny: https://inkbunny.net/s/3377727
+  sofurry: https://www.sofurry.com/view/2156241
+  weasyl: https://www.weasyl.com/~badmanners/submissions/2396279/c-rose-s-binge
+  mastodon: https://meow.social/@BadManners/112827108738415986
 tags:
   - oral vore
   - cock vore
diff --git a/src/content/stories/tiny-accident.md b/src/content/stories/tiny-accident.md
index c84d887..3c390ac 100644
--- a/src/content/stories/tiny-accident.md
+++ b/src/content/stories/tiny-accident.md
@@ -17,10 +17,10 @@ descriptionPlaintext: >
 posts:
   eka: https://aryion.com/g4/view/994229
   furaffinity: https://www.furaffinity.net/view/56026627/
-  weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident
   inkbunny: https://inkbunny.net/s/3283508
-  mastodon: https://meow.social/@BadManners/112157812554023271
   sofurry: https://www.sofurry.com/view/2118138
+  weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident
+  mastodon: https://meow.social/@BadManners/112157812554023271
 tags:
   - anthro predator
   - anthro prey
diff --git a/src/content/stories/tomo-moku.md b/src/content/stories/tomo-moku.md
index 8d4c073..a95fc3d 100644
--- a/src/content/stories/tomo-moku.md
+++ b/src/content/stories/tomo-moku.md
@@ -25,6 +25,7 @@ tags:
   - willing predator
   - unwilling prey
   - flash fiction
+  - toki pona
 summary: |
   Lijan, a red nondescript mammal, makes his way through town to a new 'tomo moku' ("eating place"), expecting it to be a restaurant. They can't find the building among the similar looking ones, and spot a colorful and spooked bird covered in some liquid, who they ask for directions.
 
diff --git a/src/content/stories/woofer-exploration.md b/src/content/stories/woofer-exploration.md
index 26a6fbd..7ae6766 100644
--- a/src/content/stories/woofer-exploration.md
+++ b/src/content/stories/woofer-exploration.md
@@ -14,6 +14,13 @@ descriptionPlaintext: >
   The Director wakes up in the middle of the night to a little intruder, and decides to have some fun with him.
 
   This was a gift for my friend Hans! This story is sort of a non-canon sequel to his game "Director Explorer" on Itch.io, which you should definitely try out if you haven't already!
+posts:
+  eka: https://aryion.com/g4/view/1030872
+  furaffinity: https://www.furaffinity.net/view/57459921/
+  inkbunny: https://inkbunny.net/s/3377722
+  sofurry: https://www.sofurry.com/view/2156237
+  weasyl: https://www.weasyl.com/~badmanners/submissions/2396278/woofer-exploration
+  mastodon: https://meow.social/@BadManners/112827104119035982
 tags:
   - Sam Brendan
   - oral vore
diff --git a/src/content/tag-categories/body-types.yaml b/src/content/tag-categories/body-types.yaml
index 8437184..8c19cce 100644
--- a/src/content/tag-categories/body-types.yaml
+++ b/src/content/tag-categories/body-types.yaml
@@ -1,13 +1,19 @@
 name: Body types
 index: 1
 tags:
-  - anthro predator
-  - feral predator
-  - taur predator
-  - eng: ambiguous predator
-    tok: sijelo pi jan pi wawa mute li ale
-  - human prey
-  - anthro prey
-  - feral prey
-  - eng: ambiguous prey
-    tok: sijelo pi jan pi wawa lili li ale
+  - name: anthro predator
+    description: Scenarios where at least one of the predators is an anthropomorphic animal, i.e. generally regarded as a "furry".
+  - name: feral predator
+    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 }
+    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.
+  - name: anthro prey
+    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 }
+    description: Scenarios where the body type of at least one of the predators is left ambiguous.
diff --git a/src/content/tag-categories/genders.yaml b/src/content/tag-categories/genders.yaml
index cbce725..fd14dab 100644
--- a/src/content/tag-categories/genders.yaml
+++ b/src/content/tag-categories/genders.yaml
@@ -1,15 +1,31 @@
 name: Genders
 index: 2
 tags:
-  - male predator
-  - trans male predator
-  - female predator
-  - non-binary predator
-  - eng: ambiguous gender predator
-    tok: jan pi wawa mute li meli anu mije
-  - male prey
-  - female prey
-  - trans female prey
-  - non-binary prey
-  - eng: ambiguous gender prey
-    tok: jan pi wawa lili li meli anu mije
+  - name: male predator
+    description: Scenarios where at least one of the predators is a man and/or male-presenting.
+    related:
+      - trans male predator
+  - name: trans male predator
+    description: Scenarios where at least one of the predators is a man and/or male-presenting; more specifically, undergoing or or having undergone gender transition.
+    related:
+      - male predator
+  - name: female predator
+    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 }
+    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.
+  - name: female prey
+    description: Scenarios where at least one of the prey is a woman and/or female-presenting.
+    related:
+      - trans female prey
+  - name: trans female prey
+    description: Scenarios where at least one of the prey is a woman and/or female-presenting; more specifically, undergoing or having undergone gender transition.
+    related:
+      - 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 }
+    description: Scenarios where the gender at least one of the predators is left ambiguous.
diff --git a/src/content/tag-categories/other-kinks.yaml b/src/content/tag-categories/other-kinks.yaml
index b45523b..10cf7ef 100644
--- a/src/content/tag-categories/other-kinks.yaml
+++ b/src/content/tag-categories/other-kinks.yaml
@@ -1,13 +1,25 @@
 name: Other kinks
 index: 7
 tags:
-  - hyper
-  - egg play
-  - transformation
-  - netorare
-  - sizeplay
-  - inflation
-  - daddy play
-  - BDSM
-  - dubcon
-  - plushie
+  - name: hyper
+    description: Scenarios where a character has abnormally large features compared to their body, usually genitalia.
+  - name: egg play
+    description: Scenarios with sexual action involving eggs.
+    related:
+      - object vore
+  - name: transformation
+    description: Scenarios where a character changes body and/or species.
+  - name: netorare
+    description: Scenarios involving a character cheating on their partner with or without consent, and/or cuckoldry.
+  - name: sizeplay
+    description: Scenarios where a character changes size.
+  - name: inflation
+    description: Scenarios where a character expands out with the excessive intake of fluids, including semen.
+  - name: daddy play
+    description: Scenarios where a character refers to another as their "daddy" in a kinky manner as part of a sexual situation.
+  - name: BDSM
+    description: Scenarios related to bondage, sadism, masochism, dominance, and/or submission in a kinky manner.
+  - name: dubcon
+    description: Scenarios where sexual consent is dubious.
+  - name: plushie
+    description: Scenarios with sexual action involving stuffed toys or plushie-like characters.
diff --git a/src/content/tag-categories/recurring-characters.yaml b/src/content/tag-categories/recurring-characters.yaml
index b52fef2..04771b0 100644
--- a/src/content/tag-categories/recurring-characters.yaml
+++ b/src/content/tag-categories/recurring-characters.yaml
@@ -1,6 +1,9 @@
 name: Recurring characters
 index: 9
 tags:
-  - Sam Brendan
-  - Beetle
-  - Muno
+  - name: Sam Brendan
+    description: Content that includes my character and fursona [Sam, the mimic x maned wolf hybrid](https://badmanners.xyz/sam_brendan).
+  - name: Beetle
+    description: Content that includes my character [Beetle, the gryphon](https://booru.badmanners.xyz/index.php?q=post/view/3).
+  - name: Muno
+    description: Content that includes my character Muno, the snake.
diff --git a/src/content/tag-categories/relative-size.yaml b/src/content/tag-categories/relative-size.yaml
index 0a5a417..5ddbcb3 100644
--- a/src/content/tag-categories/relative-size.yaml
+++ b/src/content/tag-categories/relative-size.yaml
@@ -1,9 +1,17 @@
 name: Relative size
 index: 3
 tags:
-  - macro predator
-  - micro prey
-  - size difference
-  - similar size
-  - same size
-  - smaller predator
+  - name: macro predator
+    description: Scenarios where at least one of the predators has a size/height one or more orders of magnitude larger than average.
+  - name: micro prey
+    description: Scenarios where at least one of the prey has a size/height one or more orders of magnitude smaller than average.
+  - name: size difference
+    description: Scenarios where at least one of the predators has a notably larger size/height than one of their prey.
+  - name: similar size
+    description: Scenarios where at least one of the predators has a slightly larger size/height than one of their prey.
+  - name: same size
+    description: Scenarios where at least one of the predators has the same size/height as one of their prey, or their sizes are comparable.
+  - name: smaller predator
+    description: Scenarios where at least one of the predators has a notably smaller size/height than one of their prey.
+    related:
+      - role reversal
diff --git a/src/content/tag-categories/sexual-content.yaml b/src/content/tag-categories/sexual-content.yaml
index 7fb9f88..219cd3f 100644
--- a/src/content/tag-categories/sexual-content.yaml
+++ b/src/content/tag-categories/sexual-content.yaml
@@ -1,9 +1,25 @@
 name: Sexual content
 index: 6
 tags:
-  - nudity
-  - masturbation
-  - straight sex
-  - gay sex
-  - lesbian sex
-  - orgy
+  - name: nudity
+    description: Scenarios where a character is nude and displaying their sexual features, without necessarily participating in a sexual situation.
+  - name: masturbation
+    description: Scenarios where a character sexually stimulates themselves.
+  - name: straight sex
+    description: Scenarios where two or more characters of different genders participate in sexual activity.
+    related:
+      - orgy
+  - name: gay sex
+    description: Scenarios where two or more characters of the same gender, both men and/or male-presenting, participate in sexual activity.
+    related:
+      - orgy
+  - name: lesbian sex
+    description: Scenarios where two or more characters of the same gender, both women and/or female-presenting, participate in sexual activity.
+    related:
+      - orgy
+  - name: orgy
+    description: Scenarios where more than two characters participate in sexual activity freely.
+    related:
+      - straight sex
+      - gay sex
+      - lesbian sex
diff --git a/src/content/tag-categories/type-of-content.yaml b/src/content/tag-categories/type-of-content.yaml
index 4f1714f..849bc9c 100644
--- a/src/content/tag-categories/type-of-content.yaml
+++ b/src/content/tag-categories/type-of-content.yaml
@@ -1,7 +1,11 @@
 name: Type of content
 index: 8
 tags:
-  - request
-  - commission
-  - eng: flash fiction
-    tok: lipu lili
+  - name: request
+    description: Stories made as part of 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 }
+    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/tag-categories/types-of-vore.yaml b/src/content/tag-categories/types-of-vore.yaml
index 7c16509..1c9df3e 100644
--- a/src/content/tag-categories/types-of-vore.yaml
+++ b/src/content/tag-categories/types-of-vore.yaml
@@ -1,13 +1,21 @@
 name: Types of vore
 index: 0
 tags:
-  - eng: oral vore
-    tok: moku musi kepeken uta
-  - anal vore
-  - cock vore
-  - unbirth
-  - tail vore
-  - slit vore
-  - sheath vore
-  - nipple vore
-  - chest maw vore
+  - name: { eng: 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.
+  - name: cock vore
+    description: Scenarios where prey are consumed by the predator through their penis.
+  - name: unbirth
+    description: Scenarios where prey are consumed by the predator through their vagina/vulva.
+  - name: tail vore
+    description: Scenarios where prey are consumed by the predator through an opening or mouth in their tail.
+  - name: slit vore
+    description: Scenarios where prey are consumed by the predator through a genital slit.
+  - name: sheath vore
+    description: Scenarios where prey are consumed by the predator through a genital sheath, generally around their penis.
+  - name: nipple vore
+    description: Scenarios where prey are consumed by the predator through their nipple/breast.
+  - name: chest maw vore
+    description: Scenarios where prey are consumed by the predator through a mouth/maw on their chest cavity.
diff --git a/src/content/tag-categories/vore-related-scenarios.yaml b/src/content/tag-categories/vore-related-scenarios.yaml
index a9147b2..f6f6d61 100644
--- a/src/content/tag-categories/vore-related-scenarios.yaml
+++ b/src/content/tag-categories/vore-related-scenarios.yaml
@@ -1,20 +1,67 @@
 name: Vore-related scenarios
 index: 5
 tags:
-  - point of view
-  - long-term endo
-  - perma endo
-  - implied perma endo
-  - full tour
-  - implied full tour
-  - regurgitation
-  - implied regurgitation
-  - prey transfer
-  - object vore
-  - role reversal
-  - nested vore
-  - multiple prey
-  - messy stomach
-  - hammerspace vore
-  - bladder vore
-  - soul vore
+  - name: point of view
+    description: Scenarios where the narration takes the perspective of one of the characters, generally in first person.
+  - name: long-term endo
+    description: Scenarios where prey remains safely inside of their predator for an extended period of time.
+    related:
+      - perma endo
+      - implied perma endo
+  - name: perma endo
+    description: Scenarios where prey remains safely inside of their predator for an indefinite period of time.
+    related:
+      - long-term endo
+      - implied perma endo
+  - name: implied perma endo
+    description: Scenarios where prey is implied to remain safely inside of their predator for an indefinite period of time.
+    related:
+      - long-term endo
+      - perma endo
+  - name: full tour
+    description: Scenarios where prey passes all the way through their predator's gastrointestinal system, entering through one end and exiting through the opposite one.
+    related:
+      - implied full tour
+  - name: implied full tour
+    description: Scenarios where prey is implied to pass all the way through their predator's gastrointestinal system, entering through one end and supposedly exiting through the opposite one.
+    related:
+      - full tour
+  - name: regurgitation
+    description: Scenarios where prey are explicitly let out through the same spot they entered their predator, specifically their mouth.
+    related:
+      - implied regurgitation
+      - oral vore
+  - name: implied regurgitation
+    description: Scenarios where prey are implied to have been let out through the same spot they entered their predator, specifically their mouth.
+    related:
+      - regurgitation
+      - oral vore
+  - name: prey transfer
+    description: Scenarios where prey are transferred from one predator into another, or transferred from one part of their predator to another.
+  - name: object vore
+    description: Scenarios where predators consume objects, alongside their prey or in place of them.
+  - name: role reversal
+    description: Scenarios where predators become prey and vice-versa, where dominant characters become prey, or where submissive characters become predators.
+    related:
+      - smaller predator
+  - name: nested vore
+    description: Scenarios where a predator becomes prey to another predator, or a prey consumes another prey.
+    related:
+      - multiple prey
+      - role reversal
+  - name: multiple prey
+    description: Scenarios where one predator consumes multiple prey at once.
+  - name: messy stomach
+    description: Scenarios where a prey visits a messy stomach, usually with semi-digested food inside.
+    related:
+      - oral vore
+  - name: hammerspace vore
+    description: Scenarios where a predator's body doesn't grow proportionally around the prey they consume, either at a substantially lower rate or not at all.
+    related:
+      - multiple prey
+  - name: bladder vore
+    description: Scenarios where prey consumed through the predator's urethra end up in their bladder.
+    related:
+      - cock vore
+  - name: soul vore
+    description: Scenarios where predators consume a soul instead of their prey's body.
diff --git a/src/content/tag-categories/willingness.yaml b/src/content/tag-categories/willingness.yaml
index 68c43d3..1b1edd6 100644
--- a/src/content/tag-categories/willingness.yaml
+++ b/src/content/tag-categories/willingness.yaml
@@ -1,13 +1,19 @@
 name: Willingness
 index: 4
 tags:
-  - eng: willing predator
-    tok: jan pi wawa mute li wile e moku musi
-  - semi-willing predator
-  - unwilling predator
-  - asleep predator
-  - willing prey
-  - semi-willing prey
-  - eng: unwilling prey
-    tok: jan pi wawa lili li wile ala e moku musi
-  - asleep prey
+  - name: { eng: 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.
+  - name: unwilling predator
+    description: Scenarios where at least one of the predators participates in vore unwillingly.
+  - name: asleep predator
+    description: Scenarios where at least one of the predators participates in vore while asleep.
+  - name: willing prey
+    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 }
+    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/i18n/index.ts b/src/i18n/index.ts
index b8e1532..143c6f0 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -151,6 +151,10 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
   "game/platform_ios": {
     eng: "iOS",
   },
+  "game/warnings": {
+    eng: (platforms: string[], contentWarning: string) =>
+      `${(UI_STRINGS["game/platforms"]!.eng as (arg: string[]) => string)(platforms)}. ${contentWarning}`,
+  },
 };
 
 export function t(lang: Lang, stringOrSource: string | TranslationRecord, ...args: any[]): string {
diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro
index 345e2fd..bc1a1a5 100644
--- a/src/layouts/GameLayout.astro
+++ b/src/layouts/GameLayout.astro
@@ -22,12 +22,9 @@ const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrighte
 // 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((tag) => {
-      if (typeof tag === "string") {
-        return [tag, tag];
-      }
-      return [t(DEFAULT_LANG, tag as any), t(props.lang, tag as any)];
-    }),
+    category.data.tags.map<[string, string]>(({ name }) =>
+      typeof name === "string" ? [name, name] : [t(DEFAULT_LANG, name as any), t(props.lang, name as any)],
+    ),
   ),
 );
 const tags = props.tags.map<[string, string]>((tag) => {
diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro
index afd9f90..ef39974 100644
--- a/src/layouts/StoryLayout.astro
+++ b/src/layouts/StoryLayout.astro
@@ -34,12 +34,9 @@ const relatedStories = (await getEntries(props.relatedStories)).filter((story) =
 // 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((tag) => {
-      if (typeof tag === "string") {
-        return [tag, tag];
-      }
-      return [t(DEFAULT_LANG, tag as any), t(props.lang, tag as any)];
-    }),
+    category.data.tags.map<[string, string]>(({ name }) =>
+      typeof name === "string" ? [name, name] : [t(DEFAULT_LANG, name as any), t(props.lang, name as any)],
+    ),
   ),
 );
 const tags = props.tags.map<[string, string]>((tag) => {
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index f9b5903..afc20d3 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -1,13 +1,12 @@
 import type { APIRoute, GetStaticPaths } from "astro";
 import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
-import { marked, type RendererApi } from "marked";
-import { decode as tinyDecode } from "tiny-decode";
 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";
 
 interface ExportWebsiteInfo {
-  website: string;
+  website: Website;
   exportFormat: "bbcode" | "markdown";
 }
 
@@ -21,37 +20,6 @@ const WEBSITE_LIST = [
 
 type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never;
 
-//type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionExportFormat]> ? K : never;
-
-const bbcodeRenderer: RendererApi = {
-  strong: (text) => `[b]${text}[/b]`,
-  em: (text) => `[i]${text}[/i]`,
-  codespan: (code) => code,
-  br: () => `\n\n`,
-  link: (href, _, text) => `[url=${href}]${text}[/url]`,
-  image: (href) => `[img]${href}[/img]`,
-  text: (text) => text,
-  paragraph: (text) => `\n${text}\n`,
-  list: (body, ordered) => (ordered ? `\n[ol]\n${body}[/ol]\n` : `\n[ul]\n${body}[/ul]\n`),
-  listitem: (text) => `[li]${text}[/li]\n`,
-  blockquote: (quote) => `\n[quote]${quote}[/quote]\n`,
-  code: (code) => `\n[code]${code}[/code]\n`,
-  heading: (heading) => `\n${heading}\n`,
-  table: (header, body) => `\n[table]\n${header}${body}[/table]\n`,
-  tablerow: (content) => `[tr]\n${content}[/tr]\n`,
-  tablecell: (content, { header }) => (header ? `[th]${content}[/th]\n` : `[td]${content}[/td]\n`),
-  hr: () => `\n===\n`,
-  del: () => {
-    throw new Error("Not supported by bbcodeRenderer: del");
-  },
-  html: () => {
-    throw new Error("Not supported by bbcodeRenderer: html");
-  },
-  checkbox: () => {
-    throw new Error("Not supported by bbcodeRenderer: checkbox");
-  },
-};
-
 function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
   const link = user.data.links[website];
   if (link) {
@@ -147,7 +115,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
       if ("weasyl" in user.data.links) {
         return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
       } else if (isPreferredWebsite(user, "furaffinity")) {
-        return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`;
+        return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
       } else if (isPreferredWebsite(user, "inkbunny")) {
         return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
       } else if (isPreferredWebsite(user, "sofurry")) {
@@ -220,47 +188,40 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
   const anonymousUser = await getEntry("users", "anonymous");
   const anonymousFallback = getNameForUser(anonymousUser, anonymousUser, lang);
 
-  const description: Record<ExportWebsiteName, string> = Object.fromEntries(
-    await Promise.all(
-      WEBSITE_LIST.map(async ({ website, exportFormat }) => {
-        const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
-        const storyDescription = (
-          [
-            story.data.description,
-            `*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`,
-            t(
-              lang,
-              "export_story/writing",
-              authorsList.map((author) => u(author)),
-            ),
-            requester && t(lang, "export_story/request_for", u(requester)),
-            commissioner && t(lang, "export_story/commissioned_by", u(commissioner)),
-            ...copyrightedCharacters.map(([user, characterList]) =>
-              characterList[0] == ""
-                ? t(lang, "characters/all_characters_are_copyrighted_by", u(user))
-                : t(lang, "characters/characters_are_copyrighted_by", u(user), characterList),
-            ),
-          ].filter((data) => data) as string[]
-        )
-          .join("\n\n")
-          .replaceAll(
-            /\[([^\]]+)\]\((\/[^\)]+)\)/g,
-            (_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
-          );
-        if (exportFormat === "bbcode") {
-          return [
-            website,
-            tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription))
-              .replaceAll(/\n\n\n+/g, "\n\n")
-              .trim(),
-          ];
-        }
-        if (exportFormat === "markdown") {
-          return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
-        }
-        throw new Error(`Unhandled ExportFormat "${exportFormat}"`);
-      }),
-    ),
+  const description = Object.fromEntries(
+    WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
+      const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
+      const storyDescription = (
+        [
+          story.data.description,
+          `*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`,
+          t(
+            lang,
+            "export_story/writing",
+            authorsList.map((author) => u(author)),
+          ),
+          requester && t(lang, "export_story/request_for", u(requester)),
+          commissioner && t(lang, "export_story/commissioned_by", u(commissioner)),
+          ...copyrightedCharacters.map(([user, characterList]) =>
+            characterList[0] == ""
+              ? t(lang, "characters/all_characters_are_copyrighted_by", u(user))
+              : t(lang, "characters/characters_are_copyrighted_by", u(user), characterList),
+          ),
+        ].filter((data) => data) as string[]
+      )
+        .join("\n\n")
+        .replaceAll(
+          /\[([^\]]+)\]\((\/[^\)]+)\)/g,
+          (_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
+        );
+      if (exportFormat === "bbcode") {
+        return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n").trim()];
+      } else if (exportFormat === "markdown") {
+        return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
+      } else {
+        throw new Error(`Unhandled export format "${exportFormat}"`);
+      }
+    })
   );
 
   const storyHeader =
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index 533413e..e5ea5b4 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -84,7 +84,7 @@ export const GET: APIRoute = async ({ site }) => {
           pubDate: toNoonUTCDate(data.pubDate!),
           link: `/games/${slug}`,
           description:
-            `${t(data.lang, "game/platforms", data.platforms)}. ${data.contentWarning} ${data.descriptionPlaintext || data.description}`
+            `${t(data.lang, "game/warnings", data.platforms, data.contentWarning)} ${data.descriptionPlaintext || data.description}`
               .replaceAll(/[\n ]+/g, " ")
               .trim(),
           categories: ["game"],
@@ -104,8 +104,8 @@ export const GET: APIRoute = async ({ site }) => {
               )}</p>` +
               `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
               `<hr><p><em>${data.contentWarning.trim()}</em></p>` +
-              `<hr>${tinyDecode(await marked(body))}` +
-              `<hr>${tinyDecode(await marked(data.description))}`,
+              `<hr>${tinyDecode(marked.parse(body) as string)}` +
+              `<hr>${tinyDecode(marked.parse(data.description) as string)}`,
           ),
         })),
       ),
diff --git a/src/pages/games.astro b/src/pages/games.astro
index a157ceb..9304079 100644
--- a/src/pages/games.astro
+++ b/src/pages/games.astro
@@ -2,6 +2,7 @@
 import { Image } from "astro:assets";
 import { getCollection } from "astro:content";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
+import { t } from "../i18n";
 
 const games = (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)).sort(
   (a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime(),
@@ -16,7 +17,11 @@ const games = (await getCollection("games", (game) => !game.data.isDraft && game
     {
       games.map((game) => (
         <li>
-          <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
+          <a
+            class="text-link hover:underline focus:underline"
+            href={`/games/${game.slug}`}
+            title={t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning.trim())}
+          >
             {game.data.thumbnail ? (
               <div class="flex aspect-[630/500] max-w-[288px] justify-center">
                 <Image class="m-auto" src={game.data.thumbnail} alt={`Thumbnail for ${game.data.title}`} width={288} />
diff --git a/src/pages/index.astro b/src/pages/index.astro
index e8589ee..2ac7fca 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -2,6 +2,7 @@
 import { type CollectionEntry, getCollection } from "astro:content";
 import { Image } from "astro:assets";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
+import { t } from "../i18n";
 
 const MAX_ITEMS = 8;
 
@@ -10,6 +11,7 @@ interface LatestItemsEntry {
   thumbnail: CollectionEntry<"stories">["data"]["thumbnail"];
   href: string;
   title: string;
+  altText: string;
   pubDate: Date;
 }
 
@@ -26,6 +28,7 @@ const latestItems: LatestItemsEntry[] = [
     thumbnail: story.data.thumbnail,
     href: `/stories/${story.slug}`,
     title: story.data.title,
+    altText: t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim()),
     pubDate: story.data.pubDate!,
   })),
   games.map<LatestItemsEntry>((game) => ({
@@ -33,6 +36,7 @@ const latestItems: LatestItemsEntry[] = [
     thumbnail: game.data.thumbnail,
     href: `/games/${game.slug}`,
     title: game.data.title,
+    altText: t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning.trim()),
     pubDate: game.data.pubDate!,
   })),
 ]
@@ -63,7 +67,7 @@ const latestItems: LatestItemsEntry[] = [
       {
         latestItems.map((entry) => (
           <li class="break-inside-avoid">
-            <a class="text-link hover:underline focus:underline" href={entry.href}>
+            <a class="text-link hover:underline focus:underline" href={entry.href} title={entry.altText}>
               {entry.thumbnail ? (
                 <div class="flex aspect-square max-w-[192px] justify-center">
                   <Image class="m-auto" src={entry.thumbnail} alt={`Thumbnail for ${entry.title}`} width={192} />
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index b581b2c..4ab3aa7 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -4,6 +4,7 @@ import { Image } from "astro:assets";
 import { getCollection } from "astro:content";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
 import type { CollectionEntry } from "astro:content";
+import { t } from "../../i18n";
 
 type Props = {
   page: Page<CollectionEntry<"stories">>;
@@ -67,7 +68,11 @@ const totalPages = Math.ceil(page.total / page.size);
     {
       page.data.map((story) => (
         <li class="break-inside-avoid">
-          <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
+          <a
+            class="text-link hover:underline focus:underline"
+            href={`/stories/${story.slug}`}
+            title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
+          >
             {story.data.thumbnail ? (
               <div class="flex aspect-square max-w-[192px] justify-center">
                 <Image
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 3b6aa86..e86937f 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -2,6 +2,7 @@
 import { getCollection } from "astro:content";
 import { slug } from "github-slugger";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
+import { markdownToPlaintext } from "../utils/markdown_to_plaintext";
 
 const [stories, games, tagCategories] = await Promise.all([
   getCollection("stories"),
@@ -30,35 +31,43 @@ const seriesCollection = await getCollection("series");
   });
 
 const uncategorizedTagsSet = new Set(tagsSet);
-const categorizedTags: Array<[string, string, string[]]> = tagCategories
-  .sort((a, b) => a.data.index - b.data.index)
-  .map((category) => {
-    const tagList = category.data.tags.map((tag) => (typeof tag === "string" ? tag : tag["eng"]!));
-    tagList.forEach((tag, index) => {
-      if (index !== tagList.findLastIndex((val) => tag == val)) {
-        throw new Error(`Duplicated tag "${tag}" found in multiple tag-categories`);
-      }
-    });
-    return [
-      category.data.name,
-      category.id,
-      tagList.filter((tag) => {
-        if (draftOnlyTagsSet.has(tag)) {
-          console.log(`Omitting draft-only tag "${tag}"`);
-          return false;
+const categorizedTags: Array<[string, string, [string, string | undefined][]]> = await Promise.all(
+  tagCategories
+    .sort((a, b) => a.data.index - b.data.index)
+    .map(async (category) => {
+      const tagList = category.data.tags.map(({ name, description }) => {
+          description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ").trim();
+          return (typeof name === "string" ? [name, description] : [name["eng"]!, description]) as [
+            string,
+            string | undefined,
+          ];
+        });
+      tagList.forEach(([tag, _], index) => {
+        if (index !== tagList.findLastIndex(([otherTag, _]) => tag == otherTag)) {
+          throw new Error(`Duplicated tag "${tag}" found in multiple tag-categories lists`);
         }
-        if (tagsSet.has(tag)) {
-          uncategorizedTagsSet.delete(tag);
-        }
-        return true;
-      }),
-    ];
-  });
+      });
+      return [
+        category.data.name,
+        category.id,
+        tagList.filter(([tag, _]) => {
+          if (draftOnlyTagsSet.has(tag)) {
+            console.log(`Omitting draft-only tag "${tag}"`);
+            return false;
+          }
+          if (tagsSet.has(tag)) {
+            uncategorizedTagsSet.delete(tag);
+          }
+          return true;
+        }),
+      ];
+    }),
+);
 
 if (uncategorizedTagsSet.size > 0) {
   const tagList = [...uncategorizedTagsSet];
-  console.log("The following tags have no category:", tagList);
-  categorizedTags.push(["Uncategorized tags", "others", tagList]);
+  console.warn("The following tags have no category:", tagList);
+  // categorizedTags.push(["Uncategorized tags", "others", tagList]);
 }
 ---
 
@@ -92,9 +101,9 @@ if (uncategorizedTagsSet.size > 0) {
             {category}
           </h2>
           <ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2">
-            {tagList.map((tag) => (
+            {tagList.map(([tag, description]) => (
               <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white">
-                <a class="hover:underline focus:underline" href={`/tags/${slug(tag)}`}>
+                <a class="hover:underline focus:underline" href={`/tags/${slug(tag)}`} title={description}>
                   {tag}
                 </a>
               </li>
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index f9fac85..817adc4 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -2,11 +2,16 @@
 import type { GetStaticPaths } from "astro";
 import { Image } from "astro:assets";
 import { type CollectionEntry, getCollection } from "astro:content";
+import { Markdown } from "@astropub/md";
 import { slug } from "github-slugger";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
+import Prose from "../../components/Prose.astro";
+import { t } from "../../i18n";
 
 type Props = {
   tag: string;
+  description?: string;
+  related?: string[];
   stories: CollectionEntry<"stories">[];
   games: CollectionEntry<"games">[];
 };
@@ -16,10 +21,11 @@ type Params = {
 };
 
 export const getStaticPaths: GetStaticPaths = async () => {
-  const [stories, games, series] = await Promise.all([
+  const [stories, games, series, tagCategories] = await Promise.all([
     getCollection("stories"),
     getCollection("games"),
     getCollection("series"),
+    getCollection("tag-categories"),
   ]);
   const seriesTags = new Set(series.map((s) => s.data.name));
   const tags = new Set<string>();
@@ -33,12 +39,40 @@ export const getStaticPaths: GetStaticPaths = async () => {
       tags.add(tag);
     });
   });
+  const tagDescriptions = Object.fromEntries(
+    tagCategories.flatMap((category) =>
+      category.data.tags.reduce(
+        (acc, { 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;
+            });
+          }
+          acc.push(
+            typeof name === "string" ? [name, { description, related }] : [name["eng"]!, { description, related }],
+          );
+          return acc;
+        },
+        [] as [string, { description: string | undefined; related: string[] | undefined }][],
+      ),
+    ),
+  );
   return [...tags]
     .filter((tag) => !seriesTags.has(tag))
     .map((tag) => ({
       params: { slug: slug(tag) } satisfies Params,
       props: {
         tag,
+        description: tagDescriptions[tag].description,
+        related: tagDescriptions[tag].related,
         stories: stories
           .filter((story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag))
           .sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()),
@@ -49,28 +83,43 @@ export const getStaticPaths: GetStaticPaths = async () => {
     }));
 };
 
-const { tag, stories, games } = Astro.props;
+const { tag, description, stories, games, related } = Astro.props;
+if (!description) {
+  console.warn(`Tag "${tag}" has no description!`);
+}
 const count = stories.length + games.length;
-let tagDescription: string = "";
+let totalWorksWithTag: string = "";
 if (count == 1) {
   if (stories.length == 1) {
-    tagDescription = `One story tagged with "${tag}".`;
+    totalWorksWithTag = `One story tagged with "${tag}".`;
   } else if (games.length == 1) {
-    tagDescription = `One game tagged with "${tag}".`;
+    totalWorksWithTag = `One game tagged with "${tag}".`;
   }
 } else if (stories.length == 0) {
-  tagDescription = `${games.length} games tagged with "${tag}".`;
+  totalWorksWithTag = `${games.length} games tagged with "${tag}".`;
 } else if (games.length == 0) {
-  tagDescription = `${stories.length} stories tagged with "${tag}".`;
+  totalWorksWithTag = `${stories.length} stories tagged with "${tag}".`;
 } else {
-  tagDescription = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`;
+  totalWorksWithTag = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`;
 }
 ---
 
 <GalleryLayout pageTitle={`Works tagged "${tag}"`}>
-  <meta slot="head-description" content={`Bad Manners || ${tagDescription || tag}`} property="og:description" />
+  <meta slot="head-description" content={`Bad Manners || ${totalWorksWithTag || tag}`} property="og:description" />
   <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{tag}"</h1>
-  {tagDescription ? <p class="my-4">{tagDescription}</p> : null}
+  <div class="my-4">
+    <Prose>
+      {description ? <Markdown of={description} /> : null}
+      {
+        related?.length ? (
+          <p
+            set:html={`See also: ${related.map((relatedTag) => `<a href="/tags/${slug(relatedTag)}">${relatedTag}</a>`).join(", ")}.`}
+          />
+        ) : null
+      }
+      <p>{totalWorksWithTag}</p>
+    </Prose>
+  </div>
   {
     stories.length > 0 && (
       <section class="my-2" aria-labelledby="content-stories">
@@ -80,7 +129,11 @@ if (count == 1) {
         <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
           {stories.map((story) => (
             <li class="break-inside-avoid">
-              <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
+              <a
+                class="text-link hover:underline focus:underline"
+                href={`/stories/${story.slug}`}
+                title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
+              >
                 {story.data.thumbnail ? (
                   <div class="flex aspect-square max-w-[192px] justify-center">
                     <Image
@@ -108,7 +161,11 @@ if (count == 1) {
         <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
           {games.map((game) => (
             <li class="break-inside-avoid">
-              <a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
+              <a
+                class="text-link hover:underline focus:underline"
+                href={`/games/${game.slug}`}
+                title={t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning.trim())}
+              >
                 {game.data.thumbnail ? (
                   <div class="flex aspect-[630/500] max-w-[192px] justify-center">
                     <Image
diff --git a/src/utils/markdown_to_bbcode.ts b/src/utils/markdown_to_bbcode.ts
new file mode 100644
index 0000000..b7e6c82
--- /dev/null
+++ b/src/utils/markdown_to_bbcode.ts
@@ -0,0 +1,33 @@
+import { Marked, type RendererApi } from "marked";
+import { decode } from "tiny-decode";
+
+const renderer: RendererApi = {
+  strong: (text) => `[b]${text}[/b]`,
+  em: (text) => `[i]${text}[/i]`,
+  del: (text) => `[s]${text}[/s]`,
+  codespan: (code) => code,
+  br: () => `\n\n`,
+  link: (href, _, text) => `[url=${href}]${text}[/url]`,
+  image: (href) => `[img]${href}[/img]`,
+  text: (text) => text,
+  paragraph: (text) => `\n${text}\n`,
+  list: (body, ordered) => (ordered ? `\n[ol]\n${body}[/ol]\n` : `\n[ul]\n${body}[/ul]\n`),
+  listitem: (text) => `[li]${text}[/li]\n`,
+  blockquote: (quote) => `\n[quote]${quote}[/quote]\n`,
+  code: (code) => `\n[code]${code}[/code]\n`,
+  heading: (heading) => `\n${heading}\n`,
+  table: (header, body) => `\n[table]\n${header}${body}[/table]\n`,
+  tablerow: (content) => `[tr]\n${content}[/tr]\n`,
+  tablecell: (content, { header }) => (header ? `[th]${content}[/th]\n` : `[td]${content}[/td]\n`),
+  hr: () => `\n===\n`,
+  html: () => {
+    throw new Error("Not supported by BBCode: html");
+  },
+  checkbox: () => {
+    throw new Error("Not supported by BBCode: checkbox");
+  },
+};
+
+const bbcodeRenderer = new Marked({ renderer, async: false });
+
+export const markdownToBbcode = (text: string) => decode((bbcodeRenderer.parse(text) as string).trim());
diff --git a/src/utils/markdown_to_plaintext.ts b/src/utils/markdown_to_plaintext.ts
new file mode 100644
index 0000000..51d4335
--- /dev/null
+++ b/src/utils/markdown_to_plaintext.ts
@@ -0,0 +1,35 @@
+import { Marked, type RendererApi } from "marked";
+import { decode } from "tiny-decode";
+
+const renderer: RendererApi = {
+  strong: (text) => text,
+  em: (text) => `_${text}_`,
+  del: (text) => text,
+  codespan: (code) => code,
+  br: () => `\n\n`,
+  link: (_href, _title, text) => text,
+  text: (text) => text,
+  paragraph: (text) => `\n${text}\n`,
+  list: (body) => `\n${body}\n`,
+  listitem: (text) => `- ${text}\n`,
+  blockquote: (quote) => `\n> ${quote}\n`,
+  code: (code) => `\n${code}\n`,
+  heading: (heading) => `\n${heading}\n`,
+  table: (header, body) => `\n${header}\n---\n${body}\n`,
+  tablerow: (content) => `${content.slice(0, -3)}\n`,
+  tablecell: (content) => `${content} | `,
+  hr: () => `\n***\n`,
+  image: () => {
+    throw new Error("Not supported by plaintext: img");
+  },
+  html: () => {
+    throw new Error("Not supported by plaintext: html");
+  },
+  checkbox: () => {
+    throw new Error("Not supported by plaintext: checkbox");
+  },
+};
+
+const plaintextRenderer = new Marked({ renderer, async: false });
+
+export const markdownToPlaintext = (text: string) => decode((plaintextRenderer.parse(text) as string).trim());
diff --git a/tailwind.config.mjs b/tailwind.config.mjs
index c50aaf6..41dc640 100644
--- a/tailwind.config.mjs
+++ b/tailwind.config.mjs
@@ -22,7 +22,7 @@ export default {
         radial: "radial-gradient(var(--tw-gradient-stops))",
       },
       typography: ({ theme }) => ({
-        story: {
+        bm: {
           css: {
             "--tw-prose-body": theme("colors.stone[800]"),
             "--tw-prose-headings": theme("colors.stone[900]"),