From 37db38b613eeac1a7d839b999c4819a81b586be3 Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Sat, 30 Mar 2024 15:04:29 -0300
Subject: [PATCH] Improve i18n support and config validation

---
 package-lock.json                       |   4 +-
 package.json                            |   2 +-
 src/components/UserComponent.astro      |  12 +-
 src/content/config.ts                   |  63 +++++++---
 src/i18n/index.ts                       |  60 ++++-----
 src/layouts/GameLayout.astro            |   3 -
 src/layouts/StoryLayout.astro           |   3 -
 src/pages/api/export-story/[...slug].ts | 154 ++++++++++--------------
 src/pages/tags.astro                    |   3 -
 9 files changed, 152 insertions(+), 152 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 78fd541..2d41de1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery-badmanners-xyz",
-  "version": "1.2.0",
+  "version": "1.2.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery-badmanners-xyz",
-      "version": "1.2.0",
+      "version": "1.2.1",
       "dependencies": {
         "@astrojs/check": "^0.5.9",
         "@astrojs/rss": "^4.0.5",
diff --git a/package.json b/package.json
index 37626ab..26082b3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery-badmanners-xyz",
   "type": "module",
-  "version": "1.2.0",
+  "version": "1.2.1",
   "scripts": {
     "dev": "astro dev",
     "start": "astro dev",
diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro
index b07eb85..9f6ab8e 100644
--- a/src/components/UserComponent.astro
+++ b/src/components/UserComponent.astro
@@ -15,15 +15,11 @@ if (user.data.isAnonymous) {
 const username = t(lang, user.data.nameLang as any) || user.data.name;
 let link: string | null = null;
 if (user.data.preferredLink) {
-  if (user.data.preferredLink in user.data.links) {
-    const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
-    if (typeof preferredLink === "string") {
-      link = preferredLink;
-    } else {
-      link = preferredLink[0];
-    }
+  const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
+  if (typeof preferredLink === "string") {
+    link = preferredLink;
   } else {
-    throw new Error(`No preferredLink "${user.data.preferredLink}" for user ${user.id}`);
+    link = preferredLink[0];
   }
 }
 ---
diff --git a/src/content/config.ts b/src/content/config.ts
index 4829f65..7f931ae 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -17,6 +17,8 @@ export const WEBSITE_LIST = [
 ] as const;
 
 const localUrlRegex = /^((\/?\.\.(?=\/))+|(\.(?=\/)))?\/?[a-z0-9_-]+(\/[a-z0-9_-]+)*\/?$/;
+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 lang = z.enum(["eng", "tok"]).default("eng");
 const website = z.enum(WEBSITE_LIST);
@@ -43,7 +45,10 @@ const storiesCollection = defineCollection({
       // Optional
       isDraft: z.boolean().default(false),
       shortTitle: z.string().optional(),
-      authors: z.union([reference("users"), z.array(reference("users"))]).default("bad-manners"),
+      authors: z
+        .union([reference("users"), z.array(reference("users"))])
+        .default("bad-manners")
+        .refine(refineAuthors, "authors cannot be empty"),
       descriptionPlaintext: z.string().optional(),
       summary: z.string().optional(),
       thumbnail: image().optional(),
@@ -52,7 +57,13 @@ const storiesCollection = defineCollection({
       series: reference("series").optional(),
       commissioner: reference("users").optional(),
       requester: reference("users").optional(),
-      copyrightedCharacters: z.record(z.string(), reference("users")).default({}),
+      copyrightedCharacters: z
+        .record(z.string(), reference("users"))
+        .default({})
+        .refine(
+          refineCopyrightedCharacters,
+          "copyrightedCharacters cannot have an empty catch-all key with other keys",
+        ),
       lang,
       prev: reference("stories").nullish(),
       next: reference("stories").nullish(),
@@ -74,13 +85,22 @@ const gamesCollection = defineCollection({
       tags: z.array(z.string()),
       // Optional
       isDraft: z.boolean().default(false),
-      authors: z.union([reference("users"), z.array(reference("users"))]).default("bad-manners"),
+      authors: z
+        .union([reference("users"), z.array(reference("users"))])
+        .default("bad-manners")
+        .refine(refineAuthors, "authors cannot be empty"),
       descriptionPlaintext: z.string().optional(),
       thumbnail: image().optional(),
       thumbnailWidth: z.number().int().optional(),
       thumbnailHeight: z.number().int().optional(),
       series: reference("series").optional(),
-      copyrightedCharacters: z.record(z.string(), reference("users")).default({}),
+      copyrightedCharacters: z
+        .record(z.string(), reference("users"))
+        .default({})
+        .refine(
+          refineCopyrightedCharacters,
+          "copyrightedCharacters cannot have an empty catch-all key with other keys",
+        ),
       lang,
       relatedStories: z.array(reference("stories")).default([]),
       relatedGames: z.array(reference("games")).default([]),
@@ -91,16 +111,24 @@ const gamesCollection = defineCollection({
 const usersCollection = defineCollection({
   type: "data",
   schema: ({ image }) =>
-    z.object({
-      // Required
-      name: z.string(),
-      links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
-      // Optional
-      preferredLink: website.nullish(),
-      nameLang: z.record(lang, z.string()).default({}),
-      avatar: image().optional(),
-      isAnonymous: z.boolean().default(false),
-    }),
+    z
+      .object({
+        // Required
+        name: z.string(),
+        links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
+        // Optional
+        preferredLink: website.nullish(),
+        nameLang: z.record(lang, z.string()).default({}),
+        avatar: image().optional(),
+        isAnonymous: z.boolean().default(false),
+      })
+      .refine(
+        ({ links, preferredLink }) => !preferredLink || preferredLink in links,
+        ({ preferredLink }) => ({
+          message: `"${preferredLink}" not defined in links`,
+          path: ["preferredLink"],
+        }),
+      ),
 });
 
 const seriesCollection = defineCollection({
@@ -118,7 +146,12 @@ const tagCategoriesCollection = defineCollection({
     // Required
     name: z.string(),
     index: z.number().int(),
-    tags: z.array(z.union([z.string(), z.record(lang, z.string())])),
+    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'),
+      ]),
+    ),
   }),
 });
 
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 2c64c6c..51eed70 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -7,6 +7,29 @@ export type TranslationRecord = { [DEFAULT_LANG]: string | ((...args: any[]) =>
 };
 
 export const UI_STRINGS: Record<string, TranslationRecord> = {
+  "util/join_names": {
+    eng: (names: string[]) =>
+      names.length <= 1
+        ? names.join("")
+        : names.length == 2
+          ? `${names[0]} and ${names[1]}`
+          : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`,
+    tok: (names: string[]) => names.join(" en "),
+  },
+  "export_story/warnings": {
+    eng: (wordCount: number | string, contentWarning: string) => `*Word count: ${wordCount}. ${contentWarning}*`,
+    tok: (_wordCount: number | string, contentWarning: string) => `*${contentWarning}*`,
+  },
+  "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: (requester: string) => `Request for: ${requester}`,
+  },
+  "export_story/commissioned_by": {
+    eng: (commissioner: string) => `Commissioned by: ${commissioner}`,
+  },
   "story/return_to_stories": {
     eng: "Return to stories",
     tok: "o tawa e lipu ale",
@@ -59,24 +82,12 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
     tok: "lipu lawa",
   },
   "story/authors": {
-    eng: (authorsList: string[]) => {
-      let authorsString = `by ${authorsList[0]}`;
-      if (authorsList.length > 2) {
-        authorsString += `, ${authorsList.slice(1, authorsList.length - 1).join(", ")}, and ${authorsList[authorsList.length - 1]}`;
-      } else if (authorsList.length == 2) {
-        authorsString += ` and ${authorsList[1]}`;
-      }
-      return authorsString;
-    },
-    tok: (authorsList: string[]) => {
-      let authorsString = "lipu ni li tan ";
-      if (authorsList.length > 1) {
-        authorsString += `jan ni: ${authorsList.join(" en ")}`;
-      } else {
-        authorsString += authorsList[0];
-      }
-      return authorsString;
-    },
+    eng: (authorsList: string[]) =>
+      `by ${(UI_STRINGS["util/join_names"]!.eng as (arg: string[]) => string)(authorsList)}`,
+    tok: (authorsList: string[]) =>
+      authorsList.length > 1
+        ? `lipu ni li tan jan ni: ${(UI_STRINGS["util/join_names"]!.tok as (arg: string[]) => string)(authorsList)}`
+        : `lipu ni li tan ${authorsList[0]}`,
   },
   "story/commissioned_by": {
     eng: (arg: string) => `Commissioned by ${arg}`,
@@ -85,15 +96,10 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
     eng: (arg: string) => `Requested by ${arg}`,
   },
   "characters/characters_are_copyrighted_by": {
-    eng: (owner: string, charactersList: string[]) => {
-      if (charactersList.length == 1) {
-        return `${charactersList[0]} is © ${owner}`;
-      }
-      if (charactersList.length == 2) {
-        return `${charactersList[0]} and ${charactersList[1]} are © ${owner}`;
-      }
-      return `${charactersList.slice(0, -1).join(", ")}, and ${charactersList[charactersList.length - 1]} are © ${owner}`;
-    },
+    eng: (owner: string, charactersList: string[]) =>
+      charactersList.length == 1
+        ? `${charactersList[0]} is © ${owner}`
+        : `${(UI_STRINGS["util/join_names"]!["eng"] as (arg: string[]) => string)(charactersList)} are © ${owner}`,
   },
   "characters/all_characters_are_copyrighted_by": {
     eng: (owner: string) => `All characters are © ${owner}`,
diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro
index 5f17b12..cd178a4 100644
--- a/src/layouts/GameLayout.astro
+++ b/src/layouts/GameLayout.astro
@@ -16,9 +16,6 @@ type Props = CollectionEntry<"games">["data"];
 const { props } = Astro;
 const series = props.series && (await getEntry(props.series));
 const authors = await getEntries([props.authors].flat());
-if ("" in props.copyrightedCharacters && Object.keys(props.copyrightedCharacters).length > 1) {
-  throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
-}
 const copyrightedCharacters = await Promise.all(
   Object.values(
     Object.keys(props.copyrightedCharacters).reduce(
diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro
index 60ba7cc..3dd5829 100644
--- a/src/layouts/StoryLayout.astro
+++ b/src/layouts/StoryLayout.astro
@@ -26,9 +26,6 @@ const series = props.series && (await getEntry(props.series));
 const authors = await getEntries([props.authors].flat());
 const commissioner = props.commissioner && (await getEntry(props.commissioner));
 const requester = props.requester && (await getEntry(props.requester));
-if ("" in props.copyrightedCharacters && Object.keys(props.copyrightedCharacters).length > 1) {
-  throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
-}
 const copyrightedCharacters = await Promise.all(
   Object.values(
     Object.keys(props.copyrightedCharacters).reduce(
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index f769719..e80e460 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -5,7 +5,7 @@ import { decode as tinyDecode } from "tiny-decode";
 import { type Lang, type Website } from "../../../content/config";
 import { t } from "../../../i18n";
 
-type DescriptionFormat = "bbcode" | "markdown";
+type ExportFormat = "bbcode" | "markdown";
 
 const WEBSITE_LIST = [
   ["eka", "bbcode"],
@@ -13,9 +13,9 @@ const WEBSITE_LIST = [
   ["inkbunny", "bbcode"],
   ["sofurry", "bbcode"],
   ["weasyl", "markdown"],
-] as const satisfies [Website, DescriptionFormat][];
+] as const satisfies [Website, ExportFormat][];
 
-type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionFormat]> ? K : never;
+type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, ExportFormat]> ? K : never;
 
 const bbcodeRenderer: RendererApi = {
   strong: (text) => `[b]${text}[/b]`,
@@ -108,7 +108,7 @@ function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website)
           }
           break;
         default:
-          throw new Error(`Unhandled website "${website}" in getUsernameForWebsite`);
+          throw new Error(`Unhandled Website "${website}"`);
       }
     } else {
       return link[1].replace(/^@/, "");
@@ -117,24 +117,14 @@ function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website)
   throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
 }
 
-function isPreferredWebsite(
-  user: CollectionEntry<"users">,
-  website: Website,
-  preferredChoices: readonly Website[],
-): boolean {
-  const { preferredLink, links } = user.data;
-  if (!(website in links)) {
-    return false;
-  }
-  if (!preferredLink || preferredLink == website) {
-    return true;
-  }
-  return !preferredChoices.includes(preferredLink);
+function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): boolean {
+  const { preferredLink } = user.data;
+  return !preferredLink || preferredLink == website;
 }
 
-function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite): string {
+function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite, anonymousFallback: string): string {
   if (user.data.isAnonymous) {
-    return "anonymous";
+    return anonymousFallback;
   }
   switch (website) {
     case "eka":
@@ -148,51 +138,46 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite):
       }
       break;
     case "weasyl":
-      const weasylPreferredWebsites = ["furaffinity", "inkbunny", "sofurry"] as const;
       if ("weasyl" in user.data.links) {
         return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
-      } else if (isPreferredWebsite(user, "furaffinity", weasylPreferredWebsites)) {
+      } else if (isPreferredWebsite(user, "furaffinity")) {
         return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`;
-      } else if (isPreferredWebsite(user, "inkbunny", weasylPreferredWebsites)) {
+      } else if (isPreferredWebsite(user, "inkbunny")) {
         return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
-      } else if (isPreferredWebsite(user, "sofurry", weasylPreferredWebsites)) {
+      } else if (isPreferredWebsite(user, "sofurry")) {
         return `<sf:${getUsernameForWebsite(user, "sofurry")}>`;
       }
       break;
     case "inkbunny":
-      const inkbunnyPreferredWebsites = ["furaffinity", "sofurry", "weasyl"] as const;
       if ("inkbunny" in user.data.links) {
         return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
-      } else if (isPreferredWebsite(user, "furaffinity", inkbunnyPreferredWebsites)) {
+      } else if (isPreferredWebsite(user, "furaffinity")) {
         return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
-      } else if (isPreferredWebsite(user, "sofurry", inkbunnyPreferredWebsites)) {
+      } else if (isPreferredWebsite(user, "sofurry")) {
         return `[sf]${getUsernameForWebsite(user, "sofurry")}[/sf]`;
-      } else if (isPreferredWebsite(user, "weasyl", inkbunnyPreferredWebsites)) {
+      } else if (isPreferredWebsite(user, "weasyl")) {
         return `[weasyl]${getUsernameForWebsite(user, "weasyl")}[/weasyl]`;
       }
       break;
     case "sofurry":
-      const sofurryPreferredWebsites = ["furaffinity", "inkbunny"] as const;
       if ("sofurry" in user.data.links) {
         return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
-      } else if (isPreferredWebsite(user, "furaffinity", sofurryPreferredWebsites)) {
+      } else if (isPreferredWebsite(user, "furaffinity")) {
         return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
-      } else if (isPreferredWebsite(user, "inkbunny", sofurryPreferredWebsites)) {
+      } else if (isPreferredWebsite(user, "inkbunny")) {
         return `ib!${getUsernameForWebsite(user, "inkbunny")}`;
       }
       break;
     default:
-      throw new Error(`Unhandled website "${website}" in getLinkForUser`);
+      throw new Error(`Unhandled ExportWebsite "${website}"`);
   }
   if (user.data.preferredLink) {
-    if (user.data.preferredLink in user.data.links) {
-      const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
-      return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
-    } else {
-      throw new Error(`No preferredLink "${user.data.preferredLink}" for user ${user.id}`);
-    }
+    const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
+    return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
   }
-  throw new Error(`No "${website}" link for user "${user.id}" (consider setting preferredLink)`);
+  throw new Error(
+    `No matching "${website}" link for user "${user.id}" (consider setting their "preferredLink" property)`,
+  );
 }
 
 function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
@@ -222,55 +207,47 @@ export const getStaticPaths: GetStaticPaths = async () => {
 
 export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
   const { lang } = story.data;
-  if (
-    story.data.copyrightedCharacters &&
-    "" in story.data.copyrightedCharacters &&
-    Object.keys(story.data.copyrightedCharacters).length > 1
-  ) {
-    throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
-  }
-  const charactersPerUser =
-    story.data.copyrightedCharacters &&
-    Object.keys(story.data.copyrightedCharacters).reduce(
-      (acc, character) => {
-        const key = story.data.copyrightedCharacters[character].id;
-        if (!(key in acc)) {
-          acc[key] = [];
-        }
-        acc[key].push(character);
-        return acc;
-      },
-      {} as Record<
-        CollectionEntry<"users">["id"],
-        (typeof story.data.copyrightedCharacters extends Record<infer K, any> ? K : never)[]
-      >,
-    );
+  const copyrightedCharacters = await Promise.all(
+    Object.values(
+      Object.keys(story.data.copyrightedCharacters).reduce(
+        (acc, character) => {
+          const user = story.data.copyrightedCharacters[character];
+          if (!(user.id in acc)) {
+            acc[user.id] = [getEntry(user), []];
+          }
+          acc[user.id][1].push(character);
+          return acc;
+        },
+        {} as Record<string, [Promise<CollectionEntry<"users">>, string[]]>,
+      ),
+    ).map(async ([userPromise, characters]) => [await userPromise, characters] as [CollectionEntry<"users">, string[]]),
+  );
+  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 anonymousUser = await getEntry("users", "anonymous");
+  const anonymousFallback = getNameForUser(anonymousUser, anonymousUser, lang);
 
   const description: Record<ExportWebsite, string> = Object.fromEntries(
     await Promise.all(
       WEBSITE_LIST.map(async ([website, exportFormat]) => {
-        const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website);
+        const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
         const storyDescription = (
           [
             story.data.description,
-            `*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`,
-            "Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" "),
-            story.data.requester && "Request for: " + u(await getEntry(story.data.requester)),
-            story.data.commissioner && "Commissioned by: " + u(await getEntry(story.data.commissioner)),
-            ...(await Promise.all(
-              (Object.keys(charactersPerUser) as CollectionEntry<"users">["id"][]).map(async (id) => {
-                const user = u(await getEntry("users", id));
-                const characterList = charactersPerUser[id];
-                if (characterList[0] == "") {
-                  return `All characters are © ${user}`;
-                } else if (characterList.length > 2) {
-                  return `${characterList.slice(0, characterList.length - 1).join(", ")}, and ${characterList[characterList.length - 1]} are © ${user}`;
-                } else if (characterList.length > 1) {
-                  return `${characterList[0]} and ${characterList[1]} are © ${user}`;
-                }
-                return `${characterList[0]} is © ${user}`;
-              }),
-            )),
+            t(lang, "export_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")
@@ -290,25 +267,22 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
         if (exportFormat === "markdown") {
           return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
         }
-        throw new Error(`Unknown exportFormat "${exportFormat}"`);
+        throw new Error(`Unhandled ExportFormat "${exportFormat}"`);
       }),
     ),
   );
 
-  const anonymousUser = await getEntry("users", "anonymous");
-  const authorsNames = (await getEntries([story.data.authors].flat())).map((author) =>
-    getNameForUser(author, anonymousUser, lang),
-  );
-  const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
-  const requester = story.data.requester && (await getEntry(story.data.requester));
-
   const storyHeader =
     `${story.data.title}\n` +
-    `${t(lang, "story/authors", authorsNames)}\n` +
+    `${t(
+      lang,
+      "story/authors",
+      authorsList.map((author) => getNameForUser(author, anonymousUser, lang)),
+    )}\n` +
     (commissioner ? `${t(lang, "story/commissioned_by", getNameForUser(commissioner, anonymousUser, lang))}\n` : "") +
     (requester ? `${t(lang, "story/requested_by", getNameForUser(requester, anonymousUser, lang))}\n` : "");
 
-  const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll("\\*", "*").replaceAll("\\=", "=")}`
+  const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}`
     .replaceAll(/\n\n\n+/g, "\n\n")
     .trim();
 
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 0c10217..ebd1ca6 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -52,9 +52,6 @@ const categorizedTags: Array<[string, string, string[]]> = tagCategories
       if (typeof tag === "string") {
         return tag;
       }
-      if (!("eng" in tag)) {
-        throw new Error(`Object-formatted tag must have an "eng" key: ${tag}`);
-      }
       return tag["eng"]!;
     });
     tagList.forEach((tag, index) => {