From 17ef8c652c0737a40dfbb010e88caeee58ac7b50 Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Fri, 26 Jul 2024 15:48:57 -0300
Subject: [PATCH] Improve schema and update tags

- Make constants in schema explicit
- Enumerate tag-categories
- More i18n utilities
- Better anonymous user support without special field
- Remove most tuples and unchecked type-casting
---
 package-lock.json                             |  4 +-
 package.json                                  |  2 +-
 src/components/UserComponent.astro            | 13 +--
 src/content/config.ts                         | 97 ++++++++++---------
 src/content/games/crossing-over.md            |  2 +-
 src/content/stories/accommodation.md          |  2 +-
 src/content/stories/addictive-additions.md    |  2 +-
 src/content/stories/better-in-bully-batter.md |  2 +-
 src/content/stories/latest-catch.md           |  3 +-
 src/content/stories/overzealous-zenko.md      |  2 +-
 src/content/stories/team-effort.md            |  1 +
 ...ypes-of-vore.yaml => 1-types-of-vore.yaml} |  2 +-
 ...ters.yaml => 10-recurring-characters.yaml} |  2 +-
 .../{body-types.yaml => 2-body-types.yaml}    |  2 +-
 .../{genders.yaml => 3-genders.yaml}          |  2 +-
 ...elative-size.yaml => 4-relative-size.yaml} |  2 +-
 .../{willingness.yaml => 5-willingness.yaml}  |  2 +-
 ...ios.yaml => 6-vore-related-scenarios.yaml} |  2 +-
 ...ual-content.yaml => 7-sexual-content.yaml} |  2 +-
 .../{other-kinks.yaml => 8-other-kinks.yaml}  |  2 +-
 ...of-content.yaml => 9-type-of-content.yaml} |  2 +-
 src/content/users/anonymous.yaml              |  3 +-
 src/content/users/bad-manners.yaml            |  2 +-
 src/i18n/index.ts                             | 76 +++++++++------
 src/layouts/GameLayout.astro                  | 16 +--
 src/layouts/StoryLayout.astro                 | 16 +--
 src/pages/api/export-story/[...slug].ts       | 29 ++----
 src/pages/feed.xml.ts                         |  2 +-
 src/pages/index.astro                         |  2 +-
 src/pages/stories/[page].astro                |  7 +-
 src/pages/tags.astro                          | 44 ++++++---
 src/pages/tags/[slug].astro                   | 80 ++++++---------
 src/utils/get_username_for_lang.ts            | 11 ++-
 src/utils/is_anonymous_user.ts                |  6 ++
 34 files changed, 223 insertions(+), 221 deletions(-)
 rename src/content/tag-categories/{types-of-vore.yaml => 1-types-of-vore.yaml} (99%)
 rename src/content/tag-categories/{recurring-characters.yaml => 10-recurring-characters.yaml} (97%)
 rename src/content/tag-categories/{body-types.yaml => 2-body-types.yaml} (99%)
 rename src/content/tag-categories/{genders.yaml => 3-genders.yaml} (99%)
 rename src/content/tag-categories/{relative-size.yaml => 4-relative-size.yaml} (99%)
 rename src/content/tag-categories/{willingness.yaml => 5-willingness.yaml} (99%)
 rename src/content/tag-categories/{vore-related-scenarios.yaml => 6-vore-related-scenarios.yaml} (99%)
 rename src/content/tag-categories/{sexual-content.yaml => 7-sexual-content.yaml} (99%)
 rename src/content/tag-categories/{other-kinks.yaml => 8-other-kinks.yaml} (99%)
 rename src/content/tag-categories/{type-of-content.yaml => 9-type-of-content.yaml} (97%)
 create mode 100644 src/utils/is_anonymous_user.ts

diff --git a/package-lock.json b/package-lock.json
index 1be4ca0..7fc89b6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery-badmanners-xyz",
-  "version": "1.5.4",
+  "version": "1.5.5",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery-badmanners-xyz",
-      "version": "1.5.4",
+      "version": "1.5.5",
       "dependencies": {
         "@astrojs/check": "^0.8.2",
         "@astrojs/rss": "^4.0.7",
diff --git a/package.json b/package.json
index 6084abb..28e83e4 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery-badmanners-xyz",
   "type": "module",
-  "version": "1.5.4",
+  "version": "1.5.5",
   "scripts": {
     "dev": "astro dev",
     "start": "astro dev",
diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro
index e8cd6d3..18ba37e 100644
--- a/src/components/UserComponent.astro
+++ b/src/components/UserComponent.astro
@@ -1,5 +1,5 @@
 ---
-import { type CollectionEntry, getEntry } from "astro:content";
+import { type CollectionEntry } from "astro:content";
 import { type Lang } from "../content/config";
 import { getUsernameForLang } from "../utils/get_username_for_lang";
 
@@ -9,13 +9,10 @@ type Props = {
 };
 
 let { user, lang } = Astro.props;
-if (user.data.isAnonymous) {
-  user = await getEntry("users", "anonymous");
-}
 const username = getUsernameForLang(user, lang);
 let link: string | null = null;
 if (user.data.preferredLink) {
-  const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
+  const preferredLink = user.data.links[user.data.preferredLink]!;
   if (typeof preferredLink === "string") {
     link = preferredLink;
   } else {
@@ -25,11 +22,11 @@ if (user.data.preferredLink) {
 ---
 
 {
-  user.data.isAnonymous || !user.data.preferredLink ? (
-    <span>{username}</span>
-  ) : (
+  link ? (
     <a href={link} class="text-link underline" target="_blank">
       {username}
     </a>
+  ) : (
+    <span>{username}</span>
   )
 }
diff --git a/src/content/config.ts b/src/content/config.ts
index 7993dfe..2956919 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -16,6 +16,8 @@ export const WEBSITE_LIST = [
 ] as const;
 export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const;
 export const DEFAULT_LANG = "eng";
+export const DEFAULT_AUTHOR = "bad-manners";
+export const ANONYMOUS_USER = "anonymous";
 
 // Validators
 
@@ -27,13 +29,34 @@ const inkbunnyPostUrlRegex = /^(?:https:\/\/)(?:www\.)?inkbunny\.net\/s\/([1-9]\
 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;
-const refineCopyrightedCharacters = (value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1;
+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
 
 export const adjustDateForUTCOffset = (date: Date) =>
   new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
+export 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]!,
+  };
+};
 
 // Types
 
@@ -46,27 +69,15 @@ const mastodonPost = z
     user: z.string(),
     postId: z.string(),
   })
-  .or(
-    z.string().transform((mastodonPost, ctx) => {
-      const match = mastodonPostUrlRegex.exec(mastodonPost);
-      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],
-      };
-    }),
-  );
+  .or(z.string().transform(parseMastodonPostUrl));
+const authors = z
+  .union([reference("users"), z.array(reference("users"))])
+  .default(DEFAULT_AUTHOR)
+  .refine(...refineAuthors);
 const copyrightedCharacters = z
   .record(z.string(), reference("users"))
   .default({})
-  .refine(refineCopyrightedCharacters, `"copyrightedCharacters" cannot mix empty catch-all key with other keys`);
+  .refine(...refineCopyrightedCharacters);
 
 export type Lang = z.output<typeof lang>;
 export type Website = z.infer<typeof website>;
@@ -89,10 +100,7 @@ const storiesCollection = defineCollection({
       pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
       isDraft: z.boolean().default(false),
       shortTitle: z.string().optional(),
-      authors: z
-        .union([reference("users"), z.array(reference("users"))])
-        .default("bad-manners")
-        .refine(refineAuthors, `"authors" cannot be empty`),
+      authors,
       summary: z.string().optional(),
       thumbnail: image().optional(),
       thumbnailWidth: z.number().int().optional(),
@@ -108,13 +116,14 @@ const storiesCollection = defineCollection({
       relatedGames: z.array(reference("games")).default([]),
       posts: z
         .object({
-          eka: z.string().regex(ekaPostUrlRegex).optional(),
-          furaffinity: z.string().regex(furaffinityPostUrlRegex).optional(),
-          weasyl: z.string().regex(weasylPostUrlRegex).optional(),
-          inkbunny: z.string().regex(inkbunnyPostUrlRegex).optional(),
-          sofurry: z.string().regex(sofurryPostUrlRegex).optional(),
-          mastodon: mastodonPost.optional(),
+          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({}),
     }),
 });
@@ -131,10 +140,7 @@ const gamesCollection = defineCollection({
       // Optional
       pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
       isDraft: z.boolean().default(false),
-      authors: z
-        .union([reference("users"), z.array(reference("users"))])
-        .default("bad-manners")
-        .refine(refineAuthors, `"authors" cannot be empty`),
+      authors,
       thumbnail: image().optional(),
       thumbnailWidth: z.number().int().optional(),
       thumbnailHeight: z.number().int().optional(),
@@ -146,13 +152,14 @@ const gamesCollection = defineCollection({
       relatedGames: z.array(reference("games")).default([]),
       posts: z
         .object({
-          eka: z.string().regex(ekaPostUrlRegex).optional(),
-          furaffinity: z.string().regex(furaffinityPostUrlRegex).optional(),
-          weasyl: z.string().regex(weasylPostUrlRegex).optional(),
-          inkbunny: z.string().regex(inkbunnyPostUrlRegex).optional(),
-          sofurry: z.string().regex(sofurryPostUrlRegex).optional(),
-          mastodon: mastodonPost.optional(),
+          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({}),
     }),
 });
@@ -169,9 +176,8 @@ const usersCollection = defineCollection({
         links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
         // Optional
         preferredLink: website.nullish(),
-        nameLang: z.record(lang, z.string()).default({}),
+        lang: z.record(lang, z.string()).default({}),
         avatar: image().optional(),
-        isAnonymous: z.boolean().default(false),
       })
       .refine(
         ({ links, preferredLink }) => !preferredLink || preferredLink in links,
@@ -187,7 +193,7 @@ const seriesCollection = defineCollection({
   schema: z.object({
     // Required
     name: z.string(),
-    url: z.string().regex(/^(\/[a-z0-9_-]+)*\/?$/, `"url" must be a local URL`),
+    url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`),
   }),
 });
 
@@ -199,10 +205,7 @@ const tagCategoriesCollection = defineCollection({
     index: z.number().int(),
     tags: z.array(
       z.object({
-        name: z.union([
-          z.string(),
-          z.intersection(z.object({ [DEFAULT_LANG]: z.string() }), z.record(lang, z.string())),
-        ]),
+        name: z.union([z.string(), z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()))]),
         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 ac9f427..8135d42 100644
--- a/src/content/games/crossing-over.md
+++ b/src/content/games/crossing-over.md
@@ -38,7 +38,7 @@ tags:
   - non-binary prey
   - micro prey
   - soul vore
-  - implied perma endo
+  - long-term endo
 ---
 
 <iframe
diff --git a/src/content/stories/accommodation.md b/src/content/stories/accommodation.md
index 40dd5e3..0a1efdf 100644
--- a/src/content/stories/accommodation.md
+++ b/src/content/stories/accommodation.md
@@ -27,7 +27,7 @@ tags:
   - unwilling prey
   - willing prey
   - size difference
-  - long-term endo
+  - implied perma endo
   - straight sex
 ---
 
diff --git a/src/content/stories/addictive-additions.md b/src/content/stories/addictive-additions.md
index b1a24f8..2a4d7cd 100644
--- a/src/content/stories/addictive-additions.md
+++ b/src/content/stories/addictive-additions.md
@@ -29,7 +29,7 @@ tags:
   - willing prey
   - semi-willing prey
   - similar size
-  - perma endo
+  - implied perma endo
   - straight sex
   - gay sex
   - hyper
diff --git a/src/content/stories/better-in-bully-batter.md b/src/content/stories/better-in-bully-batter.md
index 55e0495..d48fe06 100644
--- a/src/content/stories/better-in-bully-batter.md
+++ b/src/content/stories/better-in-bully-batter.md
@@ -26,7 +26,7 @@ tags:
   - willing predator
   - unwilling prey
   - similar size
-  - perma endo
+  - implied perma endo
   - straight sex
   - gay sex
   - orgy
diff --git a/src/content/stories/latest-catch.md b/src/content/stories/latest-catch.md
index 5783e4e..7e7df5e 100644
--- a/src/content/stories/latest-catch.md
+++ b/src/content/stories/latest-catch.md
@@ -26,7 +26,8 @@ tags:
   - willing prey
   - size difference
   - masturbation
-  - perma endo
+  - long-term endo
+  - implied perma endo
   - flash fiction
 ---
 
diff --git a/src/content/stories/overzealous-zenko.md b/src/content/stories/overzealous-zenko.md
index 360ae8a..9123068 100644
--- a/src/content/stories/overzealous-zenko.md
+++ b/src/content/stories/overzealous-zenko.md
@@ -26,7 +26,7 @@ tags:
   - willing predator
   - unwilling prey
   - size difference
-  - perma endo
+  - implied perma endo
   - request
 requester: dee-lumeni
 copyrightedCharacters:
diff --git a/src/content/stories/team-effort.md b/src/content/stories/team-effort.md
index 57d12fa..3320ea5 100644
--- a/src/content/stories/team-effort.md
+++ b/src/content/stories/team-effort.md
@@ -26,6 +26,7 @@ tags:
   - semi-willing predator
   - willing predator
   - willing prey
+  - long-term endo
   - same size
   - hyper
   - inflation
diff --git a/src/content/tag-categories/types-of-vore.yaml b/src/content/tag-categories/1-types-of-vore.yaml
similarity index 99%
rename from src/content/tag-categories/types-of-vore.yaml
rename to src/content/tag-categories/1-types-of-vore.yaml
index 1c9df3e..1d01e6f 100644
--- a/src/content/tag-categories/types-of-vore.yaml
+++ b/src/content/tag-categories/1-types-of-vore.yaml
@@ -1,5 +1,5 @@
 name: Types of vore
-index: 0
+index: 1
 tags:
   - name: { eng: oral vore, tok: moku musi kepeken uta }
     description: Scenarios where prey are consumed by the predator through their mouth.
diff --git a/src/content/tag-categories/recurring-characters.yaml b/src/content/tag-categories/10-recurring-characters.yaml
similarity index 97%
rename from src/content/tag-categories/recurring-characters.yaml
rename to src/content/tag-categories/10-recurring-characters.yaml
index 04771b0..ad2f9dc 100644
--- a/src/content/tag-categories/recurring-characters.yaml
+++ b/src/content/tag-categories/10-recurring-characters.yaml
@@ -1,5 +1,5 @@
 name: Recurring characters
-index: 9
+index: 10
 tags:
   - name: Sam Brendan
     description: Content that includes my character and fursona [Sam, the mimic x maned wolf hybrid](https://badmanners.xyz/sam_brendan).
diff --git a/src/content/tag-categories/body-types.yaml b/src/content/tag-categories/2-body-types.yaml
similarity index 99%
rename from src/content/tag-categories/body-types.yaml
rename to src/content/tag-categories/2-body-types.yaml
index 8c19cce..829e7b8 100644
--- a/src/content/tag-categories/body-types.yaml
+++ b/src/content/tag-categories/2-body-types.yaml
@@ -1,5 +1,5 @@
 name: Body types
-index: 1
+index: 2
 tags:
   - name: anthro predator
     description: Scenarios where at least one of the predators is an anthropomorphic animal, i.e. generally regarded as a "furry".
diff --git a/src/content/tag-categories/genders.yaml b/src/content/tag-categories/3-genders.yaml
similarity index 99%
rename from src/content/tag-categories/genders.yaml
rename to src/content/tag-categories/3-genders.yaml
index fd14dab..ffdaa6a 100644
--- a/src/content/tag-categories/genders.yaml
+++ b/src/content/tag-categories/3-genders.yaml
@@ -1,5 +1,5 @@
 name: Genders
-index: 2
+index: 3
 tags:
   - name: male predator
     description: Scenarios where at least one of the predators is a man and/or male-presenting.
diff --git a/src/content/tag-categories/relative-size.yaml b/src/content/tag-categories/4-relative-size.yaml
similarity index 99%
rename from src/content/tag-categories/relative-size.yaml
rename to src/content/tag-categories/4-relative-size.yaml
index 5ddbcb3..a123ff5 100644
--- a/src/content/tag-categories/relative-size.yaml
+++ b/src/content/tag-categories/4-relative-size.yaml
@@ -1,5 +1,5 @@
 name: Relative size
-index: 3
+index: 4
 tags:
   - 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.
diff --git a/src/content/tag-categories/willingness.yaml b/src/content/tag-categories/5-willingness.yaml
similarity index 99%
rename from src/content/tag-categories/willingness.yaml
rename to src/content/tag-categories/5-willingness.yaml
index 1b1edd6..c3855cb 100644
--- a/src/content/tag-categories/willingness.yaml
+++ b/src/content/tag-categories/5-willingness.yaml
@@ -1,5 +1,5 @@
 name: Willingness
-index: 4
+index: 5
 tags:
   - 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.
diff --git a/src/content/tag-categories/vore-related-scenarios.yaml b/src/content/tag-categories/6-vore-related-scenarios.yaml
similarity index 99%
rename from src/content/tag-categories/vore-related-scenarios.yaml
rename to src/content/tag-categories/6-vore-related-scenarios.yaml
index f6f6d61..83fb042 100644
--- a/src/content/tag-categories/vore-related-scenarios.yaml
+++ b/src/content/tag-categories/6-vore-related-scenarios.yaml
@@ -1,5 +1,5 @@
 name: Vore-related scenarios
-index: 5
+index: 6
 tags:
   - name: point of view
     description: Scenarios where the narration takes the perspective of one of the characters, generally in first person.
diff --git a/src/content/tag-categories/sexual-content.yaml b/src/content/tag-categories/7-sexual-content.yaml
similarity index 99%
rename from src/content/tag-categories/sexual-content.yaml
rename to src/content/tag-categories/7-sexual-content.yaml
index 219cd3f..8e7dba2 100644
--- a/src/content/tag-categories/sexual-content.yaml
+++ b/src/content/tag-categories/7-sexual-content.yaml
@@ -1,5 +1,5 @@
 name: Sexual content
-index: 6
+index: 7
 tags:
   - name: nudity
     description: Scenarios where a character is nude and displaying their sexual features, without necessarily participating in a sexual situation.
diff --git a/src/content/tag-categories/other-kinks.yaml b/src/content/tag-categories/8-other-kinks.yaml
similarity index 99%
rename from src/content/tag-categories/other-kinks.yaml
rename to src/content/tag-categories/8-other-kinks.yaml
index 10cf7ef..ad1f679 100644
--- a/src/content/tag-categories/other-kinks.yaml
+++ b/src/content/tag-categories/8-other-kinks.yaml
@@ -1,5 +1,5 @@
 name: Other kinks
-index: 7
+index: 8
 tags:
   - name: hyper
     description: Scenarios where a character has abnormally large features compared to their body, usually genitalia.
diff --git a/src/content/tag-categories/type-of-content.yaml b/src/content/tag-categories/9-type-of-content.yaml
similarity index 97%
rename from src/content/tag-categories/type-of-content.yaml
rename to src/content/tag-categories/9-type-of-content.yaml
index f4e1faa..b1a76f6 100644
--- a/src/content/tag-categories/type-of-content.yaml
+++ b/src/content/tag-categories/9-type-of-content.yaml
@@ -1,5 +1,5 @@
 name: Type of content
-index: 8
+index: 9
 tags:
   - name: request
     description: Stories made by someone else's request, as a gift.
diff --git a/src/content/users/anonymous.yaml b/src/content/users/anonymous.yaml
index 1c00098..725fb73 100644
--- a/src/content/users/anonymous.yaml
+++ b/src/content/users/anonymous.yaml
@@ -1,6 +1,5 @@
 name: Anonymous
-nameLang:
+lang:
   eng: anonymous
   tok: jan pi nimi ala
-isAnonymous: true
 links: {}
diff --git a/src/content/users/bad-manners.yaml b/src/content/users/bad-manners.yaml
index 6fcdbdf..015a17e 100644
--- a/src/content/users/bad-manners.yaml
+++ b/src/content/users/bad-manners.yaml
@@ -1,5 +1,5 @@
 name: Bad Manners
-nameLang:
+lang:
   eng: Bad Manners
   tok: nasin ike Pemene
 avatar: /src/assets/images/logo_bm.png
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 4826028..78ea03d 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -2,7 +2,7 @@ import { type GamePlatform, type Lang } from "../content/config";
 import { DEFAULT_LANG } from "../content/config";
 export { DEFAULT_LANG } from "../content/config";
 
-export const UI_STRINGS = {
+const UI_STRINGS = {
   "util/join_names": {
     eng: (names: string[]) =>
       names.length <= 1
@@ -12,25 +12,24 @@ export const UI_STRINGS = {
           : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`,
     tok: (names: string[]) => names.join(" en "),
   },
+  "util/capitalize": {
+    eng: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""),
+  },
+  "util/enumerate": {
+    eng: (count: number, nounSingular: string, nounPlural: string | undefined) =>
+      count !== 1 ? `${count !== 0 ? count : "zero"} ${nounPlural ?? nounSingular}` : `one ${nounSingular}`,
+    tok: (count: number, nounSingular: string, nounPlural: string | undefined) =>
+      `${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`,
+  },
   "export_story/writing": {
     eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
     tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`,
   },
   "export_story/request_for": {
-    eng: (requesterList: string | string[]) => {
-      if (typeof requesterList === "string") {
-        requesterList = [requesterList];
-      }
-      return `Request for: ${requesterList.join(" ")}`;
-    },
+    eng: (requesterList: string | string[]) => `Request for: ${[requesterList].flat().join(" ")}`,
   },
   "export_story/commissioned_by": {
-    eng: (commissionerList: string | string[]) => {
-      if (typeof commissionerList === "string") {
-        commissionerList = [commissionerList];
-      }
-      return `Commissioned by: ${commissionerList.join(" ")}`;
-    },
+    eng: (commissionerList: string | string[]) => `Commissioned by: ${[commissionerList].flat().join(" ")}`,
   },
   "story/return_to_stories": {
     eng: "Return to stories",
@@ -48,8 +47,9 @@ export const UI_STRINGS = {
     tok: "o ante e kule lipu",
   },
   "story/warnings": {
-    eng: (wordCount: number | string, contentWarning: string) => `Word count: ${wordCount}. ${contentWarning}`,
-    tok: (_wordCount: number | string, contentWarning: string) => `${contentWarning}`,
+    eng: (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,
@@ -91,20 +91,12 @@ export const UI_STRINGS = {
         : `lipu ni li tan ${authorsList[0]}`,
   },
   "story/commissioned_by": {
-    eng: (commissionersList: string | string[]) => {
-      if (typeof commissionersList === "string") {
-        commissionersList = [commissionersList];
-      }
-      return `Commissioned by ${UI_STRINGS["util/join_names"].eng(commissionersList)}`;
-    },
+    eng: (commissionersList: string | string[]) =>
+      `Commissioned by ${UI_STRINGS["util/join_names"].eng([commissionersList].flat())}`,
   },
   "story/requested_by": {
-    eng: (requestersList: string | string[]) => {
-      if (typeof requestersList === "string") {
-        requestersList = [requestersList];
-      }
-      return `Requested by ${UI_STRINGS["util/join_names"].eng(requestersList)}`;
-    },
+    eng: (requestersList: string | string[]) =>
+      `Requested by ${UI_STRINGS["util/join_names"].eng([requestersList].flat())}`,
   },
   "story/draft_warning": {
     eng: "DRAFT VERSION – DO NOT REDISTRIBUTE",
@@ -120,9 +112,16 @@ export const UI_STRINGS = {
   },
   "game/platforms": {
     eng: (platforms: GamePlatform[]) => {
-      const translatedPlatforms = platforms.map(
-        (platform) => (UI_STRINGS[`game/platform_${platform}`].eng as string | undefined) || platform,
-      );
+      if (platforms.length == 0) {
+        return "";
+      }
+      const translatedPlatforms = platforms.map((platform) => {
+        const platformLang = UI_STRINGS[`game/platform_${platform}`].eng;
+        if (!platformLang) {
+          throw new Error(`Invalid platform "${platform}"`);
+        }
+        return platformLang;
+      });
       return `A game for ${UI_STRINGS["util/join_names"].eng(translatedPlatforms)}`;
     },
   },
@@ -146,7 +145,22 @@ export const UI_STRINGS = {
   },
   "game/warnings": {
     eng: (platforms: GamePlatform[], contentWarning: string) =>
-      `${UI_STRINGS["game/platforms"].eng(platforms)}. ${contentWarning}`,
+      platforms.length > 0 ? `${UI_STRINGS["game/platforms"].eng(platforms)}. ${contentWarning}` : contentWarning,
+  },
+  "tag/total_works_with_tag": {
+    eng: (tag: string, storiesCount: number, gamesCount: number) => {
+      const content = [];
+      if (storiesCount > 0) {
+        content.push(UI_STRINGS["util/enumerate"].eng(storiesCount, "story", "stories"));
+      }
+      if (gamesCount > 0) {
+        content.push(UI_STRINGS["util/enumerate"].eng(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}".`);
+    },
   },
 } as const;
 
diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro
index 2bf2d7a..768c66b 100644
--- a/src/layouts/GameLayout.astro
+++ b/src/layouts/GameLayout.astro
@@ -27,17 +27,17 @@ const categorizedTags = Object.fromEntries(
     ),
   ),
 );
-const tags = props.tags.map<[string, string]>((tag) => {
-  const tagSlug = slug(tag);
+const tags = props.tags.map<{ id: string; name: string }>((tag) => {
+  const id = slug(tag);
   if (!(tag in categorizedTags)) {
     console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
-    return [tagSlug, tag];
+    return { id, name: tag };
   }
   if (categorizedTags[tag] == null) {
     console.warn(`No "${props.lang}" translation for tag "${tag}"`);
-    return [tagSlug, tag];
+    return { id, name: tag };
   }
-  return [tagSlug, categorizedTags[tag]!];
+  return { id, name: categorizedTags[tag]! };
 });
 const thumbnail =
   props.thumbnail &&
@@ -217,10 +217,10 @@ const thumbnail =
               Tags
             </h2>
             <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
-              {tags.map(([tagSlug, tagText]) => (
+              {tags.map(({ id, name }) => (
                 <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
-                  <a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
-                    {tagText}
+                  <a class="hover:underline focus:underline" href={`/tags/${id}`}>
+                    {name}
                   </a>
                 </li>
               ))}
diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro
index a820caf..a439c92 100644
--- a/src/layouts/StoryLayout.astro
+++ b/src/layouts/StoryLayout.astro
@@ -39,22 +39,22 @@ const categorizedTags = Object.fromEntries(
     ),
   ),
 );
-const tags = props.tags.map<[string, string]>((tag) => {
+const tags = props.tags.map<{ id: string; name: string }>((tag) => {
   const tagSlug = slug(tag);
   if (!(tag in categorizedTags)) {
     console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
-    return [tagSlug, tag];
+    return { id: tagSlug, name: tag };
   }
   if (categorizedTags[tag] == null) {
     console.warn(`No "${props.lang}" translation for tag "${tag}"`);
-    return [tagSlug, tag];
+    return { id: tagSlug, name: tag };
   }
-  return [tagSlug, categorizedTags[tag]!];
+  return { id: tagSlug, name: categorizedTags[tag]! };
 });
 const thumbnail =
   props.thumbnail &&
   (await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
-const wordCount = props.wordCount ? `${props.wordCount}` : "???";
+const wordCount = props.wordCount?.toString();
 ---
 
 <BaseLayout pageTitle={props.title}>
@@ -352,10 +352,10 @@ const wordCount = props.wordCount ? `${props.wordCount}` : "???";
               {t(props.lang, "story/tags")}
             </h2>
             <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
-              {tags.map(([tagSlug, tagText]) => (
+              {tags.map(({ id, name }) => (
                 <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
-                  <a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
-                    {tagText}
+                  <a class="hover:underline focus:underline" href={`/tags/${id}`}>
+                    {name}
                   </a>
                 </li>
               ))}
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index 431549f..32fcdfb 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -1,10 +1,11 @@
 import type { APIRoute, GetStaticPaths } from "astro";
 import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
-import type { Lang, Website } from "../../../content/config";
+import type { Website } from "../../../content/config";
 import { t } from "../../../i18n";
 import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
 import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
 import { getUsernameForLang } from "../../../utils/get_username_for_lang";
+import { isAnonymousUser } from "../../../utils/is_anonymous_user";
 
 interface ExportWebsiteInfo {
   website: Website;
@@ -97,10 +98,7 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b
   return !preferredLink || preferredLink == website;
 }
 
-function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, anonymousFallback: string): string {
-  if (user.data.isAnonymous) {
-    return anonymousFallback;
-  }
+function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName): string {
   switch (website) {
     case "eka":
       if ("eka" in user.data.links) {
@@ -155,13 +153,6 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
   );
 }
 
-function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
-  if (user.data.isAnonymous) {
-    return getUsernameForLang(anonymousUser, lang);
-  }
-  return getUsernameForLang(user, lang);
-}
-
 type Props = {
   story: CollectionEntry<"stories">;
 };
@@ -180,23 +171,21 @@ export const getStaticPaths: GetStaticPaths = async () => {
   }));
 };
 
-const ANONYMOUS_USER = await getEntry("users", "anonymous");
-
 export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
   const { lang } = story.data;
   const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters);
   const authorsList = await getEntries([story.data.authors].flat());
   const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
   const requester = story.data.requester && (await getEntry(story.data.requester));
-  const anonymousFallback = getNameForUser(ANONYMOUS_USER, ANONYMOUS_USER, lang);
 
   const description = Object.fromEntries(
     WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
-      const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
+      const u = (user: CollectionEntry<"users">) =>
+        isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website);
       const storyDescription = (
         [
           story.data.description,
-          `*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`,
+          `*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}*`,
           t(
             lang,
             "export_story/writing",
@@ -236,10 +225,10 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
     `${t(
       lang,
       "story/authors",
-      authorsList.map((author) => getNameForUser(author, ANONYMOUS_USER, lang)),
+      authorsList.map((author) => getUsernameForLang(author, lang)),
     )}\n` +
-    (commissioner ? `${t(lang, "story/commissioned_by", getNameForUser(commissioner, ANONYMOUS_USER, lang))}\n` : "") +
-    (requester ? `${t(lang, "story/requested_by", getNameForUser(requester, ANONYMOUS_USER, lang))}\n` : "");
+    (commissioner ? `${t(lang, "story/commissioned_by", getUsernameForLang(commissioner, lang))}\n` : "") +
+    (requester ? `${t(lang, "story/requested_by", getUsernameForLang(requester, lang))}\n` : "");
 
   const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}`
     .replaceAll(/\n\n\n+/g, "\n\n")
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index 2674c76..26c0d62 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -49,7 +49,7 @@ export const GET: APIRoute = async ({ site }) => {
           pubDate: toNoonUTCDate(data.pubDate!),
           link: `/stories/${slug}`,
           description:
-            `${t(data.lang, "story/warnings", data.wordCount || "???", data.contentWarning.trim())}\n\n${markdownToPlaintext(data.description)}`
+            `${t(data.lang, "story/warnings", data.wordCount, data.contentWarning.trim())}\n\n${markdownToPlaintext(data.description)}`
               .replaceAll(/[\n ]+/g, " ")
               .trim(),
           categories: ["story"],
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 5640b76..2ac7fca 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -28,7 +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()),
+    altText: t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim()),
     pubDate: story.data.pubDate!,
   })),
   games.map<LatestItemsEntry>((game) => ({
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index 843a966..52871cf 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -71,12 +71,7 @@ const totalPages = Math.ceil(page.total / page.size);
           <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(),
-            )}
+            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">
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 6fd2c9c..f1e4bb5 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -4,6 +4,12 @@ import { slug } from "github-slugger";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
 import { markdownToPlaintext } from "../utils/markdown_to_plaintext";
 
+interface Tag {
+  id: string;
+  name: string;
+  description?: string;
+}
+
 const [stories, games, tagCategories] = await Promise.all([
   getCollection("stories"),
   getCollection("games"),
@@ -32,24 +38,30 @@ const seriesCollection = await getCollection("series");
 
 const uncategorizedTagsSet = new Set(tagsSet);
 const categorizedTags = tagCategories
-  .sort((a, b) => a.data.index - b.data.index)
+  .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}")`,
+      );
+    }
+    return a.data.index - b.data.index;
+  })
   .map((category) => {
-    const tagList = category.data.tags.map(({ name, description }) => {
+    const tagList = category.data.tags.map<Tag>(({ name, description }) => {
       description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ").trim();
-      return (typeof name === "string" ? { name, description } : { name: name["eng"]!, description }) as {
-        name: string;
-        description?: string;
-      };
+      const tag = typeof name === "string" ? name : name["eng"];
+      const id = slug(tag);
+      return { id, 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`);
       }
     });
-    return [
-      category.data.name,
-      category.id,
-      tagList.filter(({ name }) => {
+    return {
+      name: category.data.name,
+      id: category.id,
+      tags: tagList.filter(({ name }) => {
         if (draftOnlyTagsSet.has(name)) {
           console.log(`Omitting draft-only tag "${name}"`);
           return false;
@@ -59,7 +71,7 @@ const categorizedTags = tagCategories
         }
         return true;
       }),
-    ] as [string, string, { name: string; description?: string }[]];
+    };
   });
 
 if (uncategorizedTagsSet.size > 0) {
@@ -92,16 +104,16 @@ if (uncategorizedTagsSet.size > 0) {
     </ul>
   </section>
   {
-    categorizedTags.map(([category, categorySlug, tagList]) =>
+    categorizedTags.map(({ name: category, id: categoryId, tags: tagList }) =>
       tagList.length > 0 ? (
-        <section class="my-2" aria-labelledby={`category-${categorySlug}`}>
-          <h2 id={`category-${categorySlug}`} class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
+        <section class="my-2" aria-labelledby={`category-${categoryId}`}>
+          <h2 id={`category-${categoryId}`} class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">
             {category}
           </h2>
           <ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2">
-            {tagList.map(({ name, description }) => (
+            {tagList.map(({ id, name, description }) => (
               <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white">
-                <a class="hover:underline focus:underline" href={`/tags/${slug(name)}`} title={description}>
+                <a class="hover:underline focus:underline" href={`/tags/${id}`} title={description}>
                   {name}
                 </a>
               </li>
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index c49006d..d4e3d76 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -7,6 +7,7 @@ import { slug } from "github-slugger";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
 import Prose from "../../components/Prose.astro";
 import { t } from "../../i18n";
+import { DEFAULT_LANG } from "../../i18n";
 
 type Props = {
   tag: string;
@@ -39,31 +40,31 @@ 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; related?: string[] }][],
-      ),
-    ),
+  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"];
+        if (key in acc) {
+          throw new Error(`Duplicated tag "${key}" found in multiple tag-categories lists`);
+        }
+        acc[key] = { description, related };
+      });
+      return acc;
+    },
+    {} as Record<string, { description?: string; related?: string[] }>,
   );
   return [...tags]
     .filter((tag) => !seriesTags.has(tag))
@@ -71,8 +72,8 @@ export const getStaticPaths: GetStaticPaths = async () => {
       params: { slug: slug(tag) } satisfies Params,
       props: {
         tag,
-        description: tagDescriptions[tag].description,
-        related: tagDescriptions[tag].related,
+        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()),
@@ -85,23 +86,9 @@ export const getStaticPaths: GetStaticPaths = async () => {
 
 const { tag, description, stories, games, related } = Astro.props;
 if (!description) {
-  console.log(`Tag "${tag}" has no description`);
-}
-const count = stories.length + games.length;
-let totalWorksWithTag: string = "";
-if (count == 1) {
-  if (stories.length == 1) {
-    totalWorksWithTag = `One story tagged with "${tag}".`;
-  } else if (games.length == 1) {
-    totalWorksWithTag = `One game tagged with "${tag}".`;
-  }
-} else if (stories.length == 0) {
-  totalWorksWithTag = `${games.length} games tagged with "${tag}".`;
-} else if (games.length == 0) {
-  totalWorksWithTag = `${stories.length} stories tagged with "${tag}".`;
-} else {
-  totalWorksWithTag = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`;
+  console.warn(`Tag "${tag}" has no description`);
 }
+const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stories.length, games.length);
 ---
 
 <GalleryLayout pageTitle={`Works tagged "${tag}"`}>
@@ -132,12 +119,7 @@ if (count == 1) {
               <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(),
-                )}
+                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">
diff --git a/src/utils/get_username_for_lang.ts b/src/utils/get_username_for_lang.ts
index df55190..e9c6871 100644
--- a/src/utils/get_username_for_lang.ts
+++ b/src/utils/get_username_for_lang.ts
@@ -1,12 +1,15 @@
 import type { CollectionEntry } from "astro:content";
-import type { Lang } from "../content/config";
+import { DEFAULT_LANG, type Lang } from "../content/config";
 
 export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string {
-  if (user.data.nameLang) {
-    if (user.data.nameLang[lang]) {
-      return user.data.nameLang[lang];
+  if (user.data.lang[DEFAULT_LANG]) {
+    if (user.data.lang[lang]) {
+      return user.data.lang[lang];
     }
     throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`);
   }
+  if (lang !== DEFAULT_LANG) {
+    console.warn(`User "${user.data.name}" ("${user.id}") has no "lang" property`);
+  }
   return user.data.name;
 }
diff --git a/src/utils/is_anonymous_user.ts b/src/utils/is_anonymous_user.ts
new file mode 100644
index 0000000..3f1142a
--- /dev/null
+++ b/src/utils/is_anonymous_user.ts
@@ -0,0 +1,6 @@
+import type { CollectionEntry } from "astro:content";
+import { ANONYMOUS_USER } from "../content/config";
+
+const ANONYMOUS_USER_ID: CollectionEntry<"users">["id"] = ANONYMOUS_USER;
+
+export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id == ANONYMOUS_USER_ID;