From 4f83ae8802f8a7b58930714e9d54d995dda930f1 Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Fri, 29 Mar 2024 22:48:36 -0300
Subject: [PATCH] Add i18n module and fix missing CopyrightedCharacters

---
 package-lock.json                             |   4 +-
 package.json                                  |   2 +-
 src/components/Authors.astro                  |  55 +-------
 src/components/CopyrightedCharacters.astro    |  67 +++-------
 .../CopyrightedCharactersItem.astro           |  10 ++
 src/components/UserComponent.astro            |   6 +-
 src/i18n/index.ts                             | 120 ++++++++++++++++++
 src/layouts/GameLayout.astro                  |  76 ++++++++---
 src/layouts/StoryLayout.astro                 |  83 ++++++------
 src/pages/api/export-story/[...slug].ts       |  41 ++----
 src/pages/tags.astro                          |   2 +-
 11 files changed, 270 insertions(+), 196 deletions(-)
 create mode 100644 src/components/CopyrightedCharactersItem.astro
 create mode 100644 src/i18n/index.ts

diff --git a/package-lock.json b/package-lock.json
index 4b00508..78fd541 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery-badmanners-xyz",
-  "version": "1.1.0",
+  "version": "1.2.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery-badmanners-xyz",
-      "version": "1.1.0",
+      "version": "1.2.0",
       "dependencies": {
         "@astrojs/check": "^0.5.9",
         "@astrojs/rss": "^4.0.5",
diff --git a/package.json b/package.json
index c4508a2..37626ab 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery-badmanners-xyz",
   "type": "module",
-  "version": "1.1.0",
+  "version": "1.2.0",
   "scripts": {
     "dev": "astro dev",
     "start": "astro dev",
diff --git a/src/components/Authors.astro b/src/components/Authors.astro
index 779efbe..0e8347c 100644
--- a/src/components/Authors.astro
+++ b/src/components/Authors.astro
@@ -1,58 +1,15 @@
 ---
-import { type CollectionEntry } from "astro:content";
 import { type Lang } from "../content/config";
-import UserComponent from "./UserComponent.astro";
+import { t } from "../i18n";
 
 type Props = {
-  authors: CollectionEntry<"users"> | CollectionEntry<"users">[];
   lang: Lang;
 };
 
-const { authors, lang } = Astro.props;
-const authorsArray = [authors].flat();
+const { lang } = Astro.props;
+const authors = Astro.slots.has("default")
+  ? (await Astro.slots.render("default")).replaceAll(/\<\/(a|span)\>\</g, "</$1><br><")
+  : "";
 ---
 
-{
-  authorsArray.length > 0 ? (
-    <p class="font-light">
-      {lang === "eng" &&
-        (authorsArray.length > 2 ? (
-          <span>
-            by{" "}
-            {authorsArray.slice(0, authorsArray.length - 1).map((author) => (
-              <Fragment>
-                <UserComponent lang="eng" user={author} />,
-              </Fragment>
-            ))}
-            and <UserComponent lang="eng" user={authorsArray[authorsArray.length - 1]} />
-          </span>
-        ) : authorsArray.length > 1 ? (
-          <span>
-            by <UserComponent lang="eng" user={authorsArray[0]} /> and{" "}
-            <UserComponent lang="eng" user={authorsArray[1]} />
-          </span>
-        ) : (
-          <span>
-            by <UserComponent lang="eng" user={authorsArray[0]} />
-          </span>
-        ))}
-      {lang === "tok" &&
-        (authorsArray.length > 1 ? (
-          <span>
-            lipu ni li tan jan ni:{" "}
-            {authorsArray.slice(0, authorsArray.length - 1).map((author) => (
-              <Fragment>
-                <UserComponent lang="tok" user={author} />
-                {" en "}
-              </Fragment>
-            ))}
-            <UserComponent lang="tok" user={authorsArray[authorsArray.length - 1]} />
-          </span>
-        ) : (
-          <span>
-            lipu ni li tan <UserComponent lang="tok" user={authorsArray[0]} />
-          </span>
-        ))}
-    </p>
-  ) : null
-}
+{authors ? <p id="authors" set:html={t(lang, "story/authors", authors.split("<br>"))} /> : null}
diff --git a/src/components/CopyrightedCharacters.astro b/src/components/CopyrightedCharacters.astro
index a1fd2db..e4f2936 100644
--- a/src/components/CopyrightedCharacters.astro
+++ b/src/components/CopyrightedCharacters.astro
@@ -1,67 +1,34 @@
 ---
 import { type CollectionEntry } from "astro:content";
 import { type Lang } from "../content/config";
+import { t } from "../i18n";
 import UserComponent from "./UserComponent.astro";
+import CopyrightedCharactersItem from "./CopyrightedCharactersItem.astro";
 
 type Props = {
-  copyrightedCharacters?: Record<string, CollectionEntry<"users">>;
+  copyrightedCharacters?: Array<[CollectionEntry<"users">, string[]]>;
   lang: Lang;
 };
 
 const { copyrightedCharacters, lang } = Astro.props;
-if (copyrightedCharacters && "" in copyrightedCharacters && Object.keys(copyrightedCharacters).length > 1) {
-  throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
-}
-const charactersPerUser =
-  copyrightedCharacters &&
-  Object.keys(copyrightedCharacters).reduce(
-    (acc, character) => {
-      const key = copyrightedCharacters[character].id;
-      if (!(key in acc)) {
-        acc[key] = [];
-      }
-      acc[key].push(character);
-      return acc;
-    },
-    {} as Record<
-      CollectionEntry<"users">["id"],
-      (typeof copyrightedCharacters extends Record<infer K, any> ? K : never)[]
-    >,
-  );
 ---
 
 {
-  charactersPerUser ? (
+  copyrightedCharacters ? (
     <section id="copyrighted-characters">
-      {lang === "eng" ? (
-        <ul>
-          {Object.values(charactersPerUser).map((characterList) => (
-            <li>
-              {characterList[0] === "" ? (
-                <span>
-                  All characters are &copy; <UserComponent lang={lang} user={copyrightedCharacters[""]} />
-                </span>
-              ) : characterList.length > 2 ? (
-                <span>
-                  {characterList.slice(0, characterList.length - 1).join(", ")}, and{" "}
-                  {characterList[characterList.length - 1]} are &copy;{" "}
-                  <UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
-                </span>
-              ) : characterList.length > 1 ? (
-                <span>
-                  {characterList[0]} and {characterList[1]} are &copy;{" "}
-                  <UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
-                </span>
-              ) : (
-                <span>
-                  {characterList[0]} is &copy;{" "}
-                  <UserComponent lang={lang} user={copyrightedCharacters[characterList[0]]} />
-                </span>
-              )}
-            </li>
-          ))}
-        </ul>
-      ) : null}
+      <ul>
+        {copyrightedCharacters.map(([owner, characterList]) => (
+          <CopyrightedCharactersItem
+            stringFunction={
+              characterList[0] === ""
+                ? (user) => t(lang, "characters/all_characters_are_copyrighted_by", user)
+                : (user) => t(lang, "characters/characters_are_copyrighted_by", user, characterList)
+            }
+          >
+            <UserComponent lang={lang} user={owner} />
+          </CopyrightedCharactersItem>
+        ))}
+      </ul>
     </section>
   ) : null
 }
diff --git a/src/components/CopyrightedCharactersItem.astro b/src/components/CopyrightedCharactersItem.astro
new file mode 100644
index 0000000..b18a8a1
--- /dev/null
+++ b/src/components/CopyrightedCharactersItem.astro
@@ -0,0 +1,10 @@
+---
+type Props = {
+  stringFunction: (_: string) => string;
+};
+
+const { stringFunction } = Astro.props;
+const owner = Astro.slots.has("default") ? await Astro.slots.render("default") : "";
+---
+
+{owner ? <li set:html={stringFunction(owner)} /> : null}
diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro
index 767f46b..b07eb85 100644
--- a/src/components/UserComponent.astro
+++ b/src/components/UserComponent.astro
@@ -1,7 +1,7 @@
 ---
-import { type CollectionEntry } from "astro:content";
+import { type CollectionEntry, getEntry } from "astro:content";
+import { t } from "../i18n";
 import { type Lang } from "../content/config";
-import { getEntry } from "astro:content";
 
 type Props = {
   lang: Lang;
@@ -12,7 +12,7 @@ let { user, lang } = Astro.props;
 if (user.data.isAnonymous) {
   user = await getEntry("users", "anonymous");
 }
-const username = user.data.nameLang[lang] || user.data.name;
+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) {
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..2c64c6c
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,120 @@
+import { type Lang } from "../content/config";
+
+export const DEFAULT_LANG = "eng" satisfies Lang;
+
+export type TranslationRecord = { [DEFAULT_LANG]: string | ((...args: any[]) => string) } & {
+  [L in Exclude<Lang, typeof DEFAULT_LANG>]?: string | ((...args: any[]) => string);
+};
+
+export const UI_STRINGS: Record<string, TranslationRecord> = {
+  "story/return_to_stories": {
+    eng: "Return to stories",
+    tok: "o tawa e lipu ale",
+  },
+  "story/return_to_series": {
+    eng: (seriesName: string) => `Return to ${seriesName}`,
+  },
+  "story/go_to_description": {
+    eng: "Go to description",
+    tok: "o tawa e toki lipu",
+  },
+  "story/toggle_dark_mode": {
+    eng: "Toggle dark mode",
+    tok: "o ante e kule lipu",
+  },
+  "story/word_count": {
+    eng: (wordCount: string | number) => `Word count: ${wordCount}.`,
+    tok: "",
+  },
+  "story/publish_date": {
+    eng: (date: string) => date,
+    tok: (date: string) => `tenpo suno ${date}`,
+  },
+  "story/description": {
+    eng: "Description",
+    tok: "toki lipu",
+  },
+  "story/summary": {
+    eng: "Summary",
+    tok: "lipu tawa tenpo lili",
+  },
+  "story/reveal_summary": {
+    eng: "Click to reveal",
+    tok: "Click to reveal summary in English",
+  },
+  "story/to_top": {
+    eng: "To top",
+    tok: "tawa sewi",
+  },
+  "story/tags": {
+    eng: "Tags",
+    tok: "nimi kulupu",
+  },
+  "story/copyright_year": {
+    eng: (year: string | number) => `© ${year}`,
+    tok: (year: string | number) => `© tenpo pi sike suno ${year}`,
+  },
+  "story/licenses": {
+    eng: "Licenses",
+    tok: "lipu lawa",
+  },
+  "story/authors": {
+    eng: (authorsList: string[]) => {
+      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;
+    },
+  },
+  "story/commissioned_by": {
+    eng: (arg: string) => `Commissioned by ${arg}`,
+  },
+  "story/requested_by": {
+    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}`;
+    },
+  },
+  "characters/all_characters_are_copyrighted_by": {
+    eng: (owner: string) => `All characters are © ${owner}`,
+  },
+};
+
+export function t(lang: Lang, stringOrSource: string | TranslationRecord, ...args: any[]): string {
+  if (typeof stringOrSource === "object") {
+    const translation = stringOrSource[lang] || stringOrSource[DEFAULT_LANG];
+    if (typeof translation === "function") {
+      return translation(...args);
+    }
+    return translation;
+  }
+  if (UI_STRINGS[stringOrSource]) {
+    const translation = UI_STRINGS[stringOrSource][lang] || UI_STRINGS[stringOrSource][DEFAULT_LANG];
+    if (typeof translation === "function") {
+      return translation(...args);
+    }
+    return translation;
+  }
+  console.warn(`No translation map found for "${stringOrSource}"`);
+  return stringOrSource;
+}
diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro
index 96b5b2e..5f17b12 100644
--- a/src/layouts/GameLayout.astro
+++ b/src/layouts/GameLayout.astro
@@ -1,25 +1,59 @@
 ---
 import { getImage } from "astro:assets";
-import { type CollectionEntry, getEntry, getEntries } from "astro:content";
+import { type CollectionEntry, getEntry, getEntries, getCollection } from "astro:content";
 import { Markdown } from "@astropub/md";
 import { slug } from "github-slugger";
+import { DEFAULT_LANG, t } from "../i18n";
 import BaseLayout from "./BaseLayout.astro";
 import Authors from "../components/Authors.astro";
 import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
 import Prose from "../components/Prose.astro";
 import MastodonComments from "../components/MastodonComments.astro";
+import UserComponent from "../components/UserComponent.astro";
 
 type Props = CollectionEntry<"games">["data"];
 
 const { props } = Astro;
 const series = props.series && (await getEntry(props.series));
 const authors = await getEntries([props.authors].flat());
-const copyrightedCharacters: Record<string, CollectionEntry<"users">> = {};
-Object.keys(props.copyrightedCharacters).forEach(async (character) => {
-  copyrightedCharacters[character] = await getEntry(props.copyrightedCharacters[character]);
-});
+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(
+      (acc, character) => {
+        const user = props.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 relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
 // const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
+const categorizedTags = Object.fromEntries(
+  (await getCollection("tag-categories")).flatMap((category) =>
+    category.data.tags.map((tag) => {
+      if (typeof tag === "string") {
+        return [tag, tag];
+      }
+      return [t(DEFAULT_LANG, tag as any), t(props.lang, tag as any)];
+    }),
+  ),
+);
+const tags = props.tags.map<[string, string]>((tag) => {
+  const tagSlug = slug(tag);
+  if (!(tag in categorizedTags)) {
+    console.log(`Tag "${tag}" doesn't have a category in tag-categories!`);
+    return [tagSlug, tag];
+  }
+  return [tagSlug, categorizedTags[tag]!];
+});
 const thumbnail =
   props.thumbnail &&
   (await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
@@ -37,8 +71,14 @@ const thumbnail =
     id="top"
     class="min-w-screen relative min-h-screen bg-radial from-bm-300 to-bm-600 px-1 pb-16 pt-20 dark:from-green-700 dark:to-green-950 print:bg-none"
   >
-    <div id="toolbox-buttons" aria-label="Toolbox" class="absolute top-0 h-[80vh] py-2 pl-2 lg:inset-y-0 lg:h-full">
-      <div class="sticky top-6 flex rounded-full bg-white px-1 py-1 shadow-md dark:bg-black print:hidden">
+    <div
+      id="toolbox-buttons"
+      aria-label="Toolbox"
+      class="pointer-events-none absolute top-0 h-[80vh] py-2 pl-2 lg:inset-y-0 lg:h-full"
+    >
+      <div
+        class="pointer-events-auto sticky top-6 flex rounded-full bg-white px-1 py-1 shadow-md dark:bg-black print:hidden"
+      >
         <a
           href={series ? series.data.url : "/games"}
           class="text-link my-1 h-9 w-9 p-2"
@@ -89,7 +129,9 @@ const thumbnail =
         id="game-information"
         class="mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200"
       >
-        <Authors authors={authors} lang={props.lang} />
+        <Authors lang={props.lang}>
+          {authors.map((author) => <UserComponent lang={props.lang} user={author} />)}
+        </Authors>
         {
           props.isDraft ? (
             <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
@@ -142,9 +184,7 @@ const thumbnail =
               year: "numeric",
             })}
           >
-            {props.lang === "tok"
-              ? `tenpo suno ${props.pubDate.toISOString().slice(undefined, 10)}`
-              : props.pubDate.toISOString().slice(undefined, 10)}
+            {t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(undefined, 10))}
           </p>
         )
       }
@@ -170,10 +210,10 @@ const thumbnail =
         <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">Tags</h2>
         <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
           {
-            props.tags.map((tag) => (
+            tags.map(([tagSlug, tagText]) => (
               <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/${slug(tag)}`}>
-                  {tag}
+                <a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
+                  {tagText}
                 </a>
               </li>
             ))
@@ -187,13 +227,9 @@ const thumbnail =
       />
     </main>
     <div class="pt-6 text-center text-xs text-black dark:text-white">
-      <span
-        >&copy; {
-          props.lang === "tok" ? `tenpo pi sike suno ${props.pubDate.getFullYear()}` : props.pubDate.getFullYear()
-        } |
-      </span>
+      <span>{t(props.lang, "story/copyright_year", props.pubDate.getFullYear())} | </span>
       <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
-        >{props.lang === "eng" ? "Licenses" : props.lang === "tok" ? "lipu lawa" : null}</a
+        >{t(props.lang, "story/licenses")}</a
       >
     </div>
   </div>
diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro
index 57cbdd0..60ba7cc 100644
--- a/src/layouts/StoryLayout.astro
+++ b/src/layouts/StoryLayout.astro
@@ -3,6 +3,7 @@ import { getImage } from "astro:assets";
 import { type CollectionEntry, getEntry, getEntries, getCollection } from "astro:content";
 import { Markdown } from "@astropub/md";
 import { slug } from "github-slugger";
+import { DEFAULT_LANG, t } from "../i18n";
 import BaseLayout from "./BaseLayout.astro";
 import Authors from "../components/Authors.astro";
 import UserComponent from "../components/UserComponent.astro";
@@ -25,10 +26,24 @@ 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));
-const copyrightedCharacters: Record<string, CollectionEntry<"users">> = {};
-Object.keys(props.copyrightedCharacters).forEach(async (character) => {
-  copyrightedCharacters[character] = await getEntry(props.copyrightedCharacters[character]);
-});
+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(
+      (acc, character) => {
+        const user = props.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 relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
 // const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
 const categorizedTags = Object.fromEntries(
@@ -37,11 +52,7 @@ const categorizedTags = Object.fromEntries(
       if (typeof tag === "string") {
         return [tag, tag];
       }
-      const key = tag["eng"]!;
-      if (props.lang in tag) {
-        return [key, tag[props.lang]!];
-      }
-      return [key, key];
+      return [t(DEFAULT_LANG, tag as any), t(props.lang, tag as any)];
     }),
   ),
 );
@@ -70,16 +81,20 @@ const thumbnail =
     id="top"
     class="min-w-screen relative min-h-screen bg-radial from-bm-300 to-bm-600 px-1 pb-16 pt-20 dark:from-green-700 dark:to-green-950 print:bg-none"
   >
-    <div id="toolbox-buttons" aria-label="Toolbox" class="absolute top-0 h-[80vh] py-2 pl-2 lg:inset-y-0 lg:h-full">
-      <div class="sticky top-6 flex rounded-full bg-white px-1 py-1 shadow-md dark:bg-black print:hidden">
+    <div
+      id="toolbox-buttons"
+      aria-label="Toolbox"
+      class="pointer-events-none absolute top-0 h-[80vh] py-2 pl-2 lg:inset-y-0 lg:h-full"
+    >
+      <div
+        class="pointer-events-auto sticky top-6 flex rounded-full bg-white px-1 py-1 shadow-md dark:bg-black print:hidden"
+      >
         <a
           href={series ? series.data.url : "/stories/1"}
           class="text-link my-1 h-9 w-9 p-2"
-          aria-label={props.lang === "eng"
-            ? `Return to ${series ? series.data.name : "stories"}`
-            : props.lang === "tok"
-              ? "o tawa e lipu ale"
-              : null}
+          aria-label={series
+            ? t(props.lang, "story/return_to_series", series.data.name)
+            : t(props.lang, "story/return_to_stories")}
         >
           <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
             <path
@@ -90,7 +105,7 @@ const thumbnail =
         <a
           href="#description"
           class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
-          aria-label={props.lang === "eng" ? "Go to description" : props.lang === "tok" ? "o tawa e toki lipu" : null}
+          aria-label={t(props.lang, "story/go_to_description")}
         >
           <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
             <path
@@ -101,7 +116,7 @@ const thumbnail =
         <button
           data-dark-mode
           class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
-          aria-label={props.lang === "eng" ? "Toggle dark mode" : props.lang === "tok" ? "o ante e kule" : null}
+          aria-label={t(props.lang, "story/toggle_dark_mode")}
         >
           <svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
             <path
@@ -161,7 +176,9 @@ const thumbnail =
         id="story-information"
         class="mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200"
       >
-        <Authors authors={authors} lang={props.lang} />
+        <Authors lang={props.lang}>
+          {authors.map((author) => <UserComponent lang={props.lang} user={author} />)}
+        </Authors>
         {
           props.isDraft ? (
             <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
@@ -185,7 +202,7 @@ const thumbnail =
         }
         <div id="content-warning">
           <p>
-            {props.lang === "eng" ? `Word count: ${props.wordCount}.` : props.lang === "tok" ? `` : null}
+            {t(props.lang, "story/word_count", props.wordCount)}
             {props.contentWarning}
           </p>
         </div>
@@ -231,15 +248,13 @@ const thumbnail =
               year: "numeric",
             })}
           >
-            {props.lang === "tok"
-              ? `tenpo suno ${props.pubDate.toISOString().slice(undefined, 10)}`
-              : props.pubDate.toISOString().slice(undefined, 10)}
+            {t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(undefined, 10))}
           </p>
         )
       }
       <section id="description" class="px-2 font-serif" aria-describedby="title-description">
         <h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
-          {props.lang === "eng" ? "Description" : props.lang === "tok" ? "toki lipu" : null}
+          {t(props.lang, "story/description")}
         </h2>
         <Prose>
           <Markdown of={props.description} />
@@ -250,15 +265,11 @@ const thumbnail =
         props.summary ? (
           <section id="summary" class="px-2 font-serif" aria-describedby="title-summary">
             <h2 id="title-summary" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
-              {props.lang === "eng" ? "Summary" : props.lang === "tok" ? "lipu tawa tenpo lili" : null}
+              {t(props.lang, "story/summary")}
             </h2>
             <details class="mb-6 mt-1 rounded-lg border border-stone-400 bg-stone-50 text-stone-800 dark:border-stone-500 dark:bg-stone-900 dark:text-stone-100">
               <summary class="rounded-lg bg-stone-200 px-2 py-1 dark:bg-stone-800">
-                {props.lang === "eng"
-                  ? "Click to reveal"
-                  : props.lang === "tok"
-                    ? "Click to reveal summary in English"
-                    : null}
+                {t(props.lang, "story/reveal_summary")}
               </summary>
               <div class="px-2 py-1">
                 <Prose>
@@ -275,7 +286,7 @@ const thumbnail =
             ><path
               d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"
             ></path></svg
-          ><span>{props.lang === "eng" ? "To top" : props.lang === "tok" ? "tawa sewi" : null}</span></a
+          ><span>{t(props.lang, "story/to_top")}</span></a
         >
       </div>
       {
@@ -334,7 +345,7 @@ const thumbnail =
       }
       <section id="tags" aria-describedby="title-tags" class="my-5">
         <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
-          {props.lang === "eng" ? "Tags" : props.lang === "tok" ? "nimi kulupu" : null}
+          {t(props.lang, "story/tags")}
         </h2>
         <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
           {
@@ -355,13 +366,9 @@ const thumbnail =
       />
     </main>
     <div class="pt-6 text-center text-xs text-black dark:text-white">
-      <span
-        >&copy; {
-          props.lang === "tok" ? `tenpo pi sike suno ${props.pubDate.getFullYear()}` : props.pubDate.getFullYear()
-        } |
-      </span>
+      <span>{t(props.lang, "story/copyright_year", props.pubDate.getFullYear())} | </span>
       <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
-        >{props.lang === "eng" ? "Licenses" : props.lang === "tok" ? "lipu lawa" : null}</a
+        >{t(props.lang, "story/licenses")}</a
       >
     </div>
   </div>
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index 5bea164..f769719 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -3,6 +3,7 @@ import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro
 import { marked, type RendererApi } from "marked";
 import { decode as tinyDecode } from "tiny-decode";
 import { type Lang, type Website } from "../../../content/config";
+import { t } from "../../../i18n";
 
 type DescriptionFormat = "bbcode" | "markdown";
 
@@ -196,9 +197,9 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite):
 
 function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
   if (user.data.isAnonymous) {
-    return anonymousUser.data.nameLang[lang] || anonymousUser.data.name;
+    return t(lang, anonymousUser.data.nameLang as any) || anonymousUser.data.name;
   }
-  return user.data.nameLang[lang] || user.data.name;
+  return t(lang, user.data.nameLang as any) || user.data.name;
 }
 
 type Props = {
@@ -253,7 +254,7 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
           [
             story.data.description,
             `*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`,
-            "Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" , "),
+            "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(
@@ -301,35 +302,11 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
   const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
   const requester = story.data.requester && (await getEntry(story.data.requester));
 
-  let storyHeader = `${story.data.title}\n`;
-  if (lang === "eng") {
-    let authorsString = `by ${authorsNames[0]}`;
-    if (authorsNames.length > 2) {
-      authorsString += `, ${authorsNames.slice(1, authorsNames.length - 1).join(", ")}, and ${authorsNames[authorsNames.length - 1]}`;
-    } else if (authorsNames.length == 2) {
-      authorsString += ` and ${authorsNames[1]}`;
-    }
-    storyHeader +=
-      `${authorsString}\n` +
-      (commissioner ? `Commissioned by ${getNameForUser(commissioner, anonymousUser, lang)}\n` : "") +
-      (requester ? `Requested by ${getNameForUser(requester, anonymousUser, lang)}\n` : "");
-  } else if (lang === "tok") {
-    let authorsString = "lipu ni li tan ";
-    if (authorsNames.length > 1) {
-      authorsString += `jan ni: ${authorsNames.join(" en ")}`;
-    } else {
-      authorsString += authorsNames[0];
-    }
-    if (commissioner) {
-      throw new Error(`No "commissioner" handler for language "tok"`);
-    }
-    if (requester) {
-      throw new Error(`No "requester" handler for language "tok"`);
-    }
-    storyHeader += `${authorsString}\n`;
-  } else {
-    throw new Error(`Unknown language "${lang}"`);
-  }
+  const storyHeader =
+    `${story.data.title}\n` +
+    `${t(lang, "story/authors", authorsNames)}\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("\\=", "=")}`
     .replaceAll(/\n\n\n+/g, "\n\n")
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 4d2a280..0c10217 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -53,7 +53,7 @@ const categorizedTags: Array<[string, string, string[]]> = tagCategories
         return tag;
       }
       if (!("eng" in tag)) {
-        throw new Error(`"{[lang]: text}" tag must have an "eng" key: ${tag}`);
+        throw new Error(`Object-formatted tag must have an "eng" key: ${tag}`);
       }
       return tag["eng"]!;
     });