From a335aff2d34240d6d97c0d51b39f99b54a04333b Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Fri, 16 Aug 2024 21:46:32 -0300
Subject: [PATCH] Better microformats support and add PUBLISH_DRAFTS envvar

---
 .prettierignore                               |  2 +
 README.md                                     |  9 +-
 astro.config.mjs                              |  1 +
 package-lock.json                             |  4 +-
 package.json                                  |  2 +-
 public/licenses.txt                           |  4 +-
 src/components/DarkModeScript.astro           |  2 +-
 src/components/MastodonComments.astro         | 45 ++++++----
 src/components/UserComponent.astro            |  7 +-
 src/content/config.ts                         |  4 +-
 src/content/stories/addictive-additions.md    |  2 +-
 src/content/stories/better-in-bully-batter.md |  2 +-
 src/content/stories/overzealous-zenko.md      |  2 +-
 src/content/stories/rose-s-binge.md           |  2 +-
 src/content/stories/team-building.md          |  2 +-
 src/content/stories/team-effort.md            |  2 +-
 src/content/stories/warped-friendship.md      |  2 +-
 src/content/stories/within-limits.md          |  2 +-
 src/layouts/GalleryLayout.astro               | 32 +++++--
 src/layouts/GameLayout.astro                  |  2 +-
 src/layouts/PublishedContentLayout.astro      | 44 ++++++----
 src/layouts/StoryLayout.astro                 |  6 +-
 src/pages/api/export-story/[...slug].ts       |  4 +-
 src/pages/feed.xml.ts                         |  8 +-
 src/pages/games.astro                         | 31 +++++--
 src/pages/games/[...slug].astro               | 11 ++-
 src/pages/index.astro                         | 87 +++++++++++++------
 src/pages/stories/[...slug].astro             | 11 ++-
 src/pages/stories/[page].astro                | 84 ++++++++++--------
 .../stories/the-lost-of-the-marshes.astro     |  4 +-
 src/pages/tags/[slug].astro                   |  2 +-
 31 files changed, 269 insertions(+), 153 deletions(-)

diff --git a/.prettierignore b/.prettierignore
index f150f24..768bd26 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1 +1,3 @@
 src/components/DarkModeScript.astro
+.astro/
+dist/
diff --git a/README.md b/README.md
index aec7760..04ebdb9 100644
--- a/README.md
+++ b/README.md
@@ -28,11 +28,12 @@ npm run prettier  # Prettier formatting
 
 ### Configuration
 
-The following optional environment variables can be set with `.env`:
+The following optional environment variables can be set within a `.env` file:
 
-| Name | Type | Description | 
-|-|-|-|
-| `APACHE_CONFIG` | boolean | Whether to generate an `.htaccess` Apache config file at the root of the output directory or not. |
+| Name             | Type    | Description                                                                                                                   |
+| ---------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `APACHE_CONFIG`  | boolean | If set to true, generates an `.htaccess` Apache config file at the root of the output directory.                              |
+| `PUBLISH_DRAFTS` | boolean | If set to true, includes drafts in the production build. Published drafts still won't be directly indexed by any other pages. |
 
 ### Export story for upload
 
diff --git a/astro.config.mjs b/astro.config.mjs
index f347120..234c0a6 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -27,6 +27,7 @@ export default defineConfig({
     env: {
       schema: {
         APACHE_CONFIG: envField.boolean({ context: "server", access: "public", default: false }),
+        PUBLISH_DRAFTS: envField.boolean({ context: "server", access: "public", default: false }),
       },
     },
   },
diff --git a/package-lock.json b/package-lock.json
index cf66dbb..7ee01e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery.badmanners.xyz",
-  "version": "1.7.4",
+  "version": "1.7.5",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery.badmanners.xyz",
-      "version": "1.7.4",
+      "version": "1.7.5",
       "hasInstallScript": true,
       "dependencies": {
         "@astrojs/check": "^0.9.2",
diff --git a/package.json b/package.json
index 57c6412..a9deb33 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery.badmanners.xyz",
   "type": "module",
-  "version": "1.7.4",
+  "version": "1.7.5",
   "scripts": {
     "postinstall": "astro sync",
     "dev": "astro dev",
diff --git a/public/licenses.txt b/public/licenses.txt
index 3bf892e..4356766 100644
--- a/public/licenses.txt
+++ b/public/licenses.txt
@@ -2,10 +2,10 @@ The source code of this website is licensed under the MIT License: https://opens
 
 The stories and games hosted on the website are copyrighted by me and licensed under CC-BY-NC-ND-4.0: https://creativecommons.org/licenses/by-nc-nd/4.0/
 
-The briefcase logo and any unattributed characters are copyrighted and trademarked by me.
+The briefcase logo, my characters, and any unattributed characters are copyrighted and trademarked by me, Bad Manners.
 
 The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1: https://opensource.org/license/ofl-1-1
 
 The generic SVG icons were created by Font Awesome and are distributed under CC-BY-4.0: https://creativecommons.org/licenses/by/4.0/
 
-All third-party trademarks and attributed characters belong to their respective owners, and I'm not affiliated with any of them.
+All third-party trademarks and attributed characters belong to their respective owners, and I (Bad Manners) am not affiliated with any of them.
diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro
index d4d4d88..5dda7eb 100644
--- a/src/components/DarkModeScript.astro
+++ b/src/components/DarkModeScript.astro
@@ -12,7 +12,7 @@
     }
     document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
       button.classList.remove("hidden");
-      button.removeAttribute("aria-hidden");
+      button.setAttribute("aria-hidden", "false");
       button.addEventListener("click", (e) => {
         e.preventDefault();
         if (colorScheme === "dark") {
diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro
index 790adf6..94c7048 100644
--- a/src/components/MastodonComments.astro
+++ b/src/components/MastodonComments.astro
@@ -26,7 +26,7 @@ const { link, instance, user, postId } = Astro.props;
   </h2>
   <div class="text-stone-800 dark:text-stone-100" id="comments">
     <p class="my-1">
-      <a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>
+      <a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>.
     </p>
   </div>
 </section>
@@ -46,7 +46,7 @@ const { link, instance, user, postId } = Astro.props;
     class="-mt-1 mr-1 animate-spin"
     fill="none"
     viewBox="0 0 24 24"
-    aria-hidden
+    aria-hidden="true"
   >
     <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
     <path
@@ -59,25 +59,32 @@ const { link, instance, user, postId } = Astro.props;
 </template>
 
 <template id="template-comment-box">
-  <div class="my-2 rounded-md border-2 border-stone-400 bg-stone-200 p-2 dark:border-stone-600 dark:bg-stone-800">
+  <div
+    role="article"
+    class="p-comment h-entry my-2 rounded-md border-2 border-stone-400 bg-stone-100 p-2 dark:border-stone-600 dark:bg-stone-800"
+  >
     <div class="ml-1">
-      <a data-author class="text-link flex items-center text-lg hover:underline focus:underline" target="_blank">
-        <img data-avatar class="mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
-        <span data-display-name></span>
+      <a
+        data-author
+        class="p-author h-card u-url text-link flex items-center text-lg hover:underline focus:underline"
+        target="_blank"
+      >
+        <img data-avatar class="u-photo mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
+        <span data-display-name class="p-nickname"></span>
       </a>
       <a
         data-post-link
-        class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"
+        class="u-url text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"
         target="_blank"
       >
-        <span class="mr-1" data-publish-date aria-label="Publish date"></span>
+        <time class="dt-published mr-1" data-publish-date aria-label="Publish date"></time>
       </a>
     </div>
-    <div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
+    <div data-content class="e-content prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
     <div class="ml-1 flex flex-row pb-2 pt-1">
       <div class="flex" aria-label="Favorites">
         <span data-favorites></span>
-        <svg style={{ width: "1.25rem", fill: "currentColor" }} class="ml-2" viewBox="0 0 576 512" aria-hidden>
+        <svg style={{ width: "1.25rem", fill: "currentColor" }} class="ml-2" viewBox="0 0 576 512" aria-hidden="true">
           <path
             d="M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"
           ></path>
@@ -85,14 +92,14 @@ const { link, instance, user, postId } = Astro.props;
       </div>
       <div class="ml-4 flex" aria-label="Reblogs">
         <span data-reblogs></span>
-        <svg style={{ width: "1.25rem", fill: "currentColor" }} class="ml-2" viewBox="0 0 512 512" aria-hidden>
+        <svg style={{ width: "1.25rem", fill: "currentColor" }} class="ml-2" viewBox="0 0 512 512" aria-hidden="true">
           <path
             d="M0 224c0 17.7 14.3 32 32 32s32-14.3 32-32c0-53 43-96 96-96H320v32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S320 19.1 320 32V64H160C71.6 64 0 135.6 0 224zm512 64c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 53-43 96-96 96H192V352c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6V448H352c88.4 0 160-71.6 160-160z"
           ></path>
         </svg>
       </div>
     </div>
-    <div data-comment-thread class="-mb-2" aria-hidden></div>
+    <div data-comment-thread class="-mb-2" aria-hidden="true"></div>
   </div>
 </template>
 
@@ -164,19 +171,21 @@ const { link, instance, user, postId } = Astro.props;
         )!;
         data.descendants.forEach((comment) => {
           const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
+          commentBox.id = `comment-${comment.id}`;
 
           const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
           commentBoxAuthor.href = comment.account.url;
           const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
           avatar.src = comment.account.avatar;
-          avatar.alt = `Profile picture of ${comment.account.username}`;
+          avatar.alt = `Avatar of ${comment.account.username}`;
           const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
           displayName.innerHTML = replaceEmojis(comment.account.display_name, comment.account.emojis);
 
           const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
           commentBoxPostLink.href = comment.url;
           const publishDate =
-            commentBoxPostLink.querySelector<HTMLElementTagNameMap["span"]>("span[data-publish-date]")!;
+            commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-publish-date]")!;
+          publishDate.setAttribute("datetime", comment.created_at);
           publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
             month: "short",
             day: "numeric",
@@ -186,8 +195,10 @@ const { link, instance, user, postId } = Astro.props;
           });
 
           if (comment.edited_at) {
-            const edited = document.createElement("span");
-            edited.className = "italic";
+            const edited = document.createElement("time");
+            edited.className = "dt-updated italic";
+            edited.setAttribute("datetime", comment.edited_at);
+            edited.setAttribute("title", comment.edited_at);
             edited.innerText = "(edited)";
             commentBoxPostLink.appendChild(edited);
           }
@@ -209,7 +220,7 @@ const { link, instance, user, postId } = Astro.props;
             commentMap[comment.id] = commentsIndex;
             const parentThreadDiv =
               commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
-            parentThreadDiv.removeAttribute("aria-hidden");
+            parentThreadDiv.setAttribute("aria-hidden", "false");
             parentThreadDiv.setAttribute("aria-label", "Replies");
             parentThreadDiv.appendChild(commentBox);
           }
diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro
index e8929d1..a78f8a8 100644
--- a/src/components/UserComponent.astro
+++ b/src/components/UserComponent.astro
@@ -7,19 +7,20 @@ type Props = {
   lang: Lang;
   user: CollectionEntry<"users">;
   class?: string;
+  rel?: string;
 };
 
-const { user, lang, class: className } = Astro.props;
+const { user, lang, class: className, rel } = Astro.props;
 const username = getUsernameForLang(user, lang);
 const link = user.data.preferredLink ? user.data.links[user.data.preferredLink]!.link : null;
 ---
 
 {
   link ? (
-    <a href={link} class:list={["h-card u-url text-link underline", className]} target="_blank">
+    <a rel={rel} href={link} class:list={[className, "h-card u-url text-link underline"]} target="_blank">
       {username}
     </a>
   ) : (
-    <span class:list={["h-card", className]}>{username}</span>
+    <span class:list={[className, "h-card"]}>{username}</span>
   )
 }
diff --git a/src/content/config.ts b/src/content/config.ts
index 8815165..71c8b51 100644
--- a/src/content/config.ts
+++ b/src/content/config.ts
@@ -235,8 +235,8 @@ const storiesCollection = defineCollection({
         thumbnail: image().optional(),
         // Optional parameters
         shortTitle: z.string().optional(),
-        commissioner: userList.optional(),
-        requester: userList.optional(),
+        commissioners: userList.optional(),
+        requesters: userList.optional(),
         summary: z.string().trim().optional(),
         thumbnailWidth: z.number().int().optional(),
         thumbnailHeight: z.number().int().optional(),
diff --git a/src/content/stories/addictive-additions.md b/src/content/stories/addictive-additions.md
index 2a4d7cd..0340e47 100644
--- a/src/content/stories/addictive-additions.md
+++ b/src/content/stories/addictive-additions.md
@@ -35,7 +35,7 @@ tags:
   - hyper
   - netorare
   - commission
-commissioner: scion
+commissioners: scion
 copyrightedCharacters:
   "": scion
 ---
diff --git a/src/content/stories/better-in-bully-batter.md b/src/content/stories/better-in-bully-batter.md
index d48fe06..abbd9b3 100644
--- a/src/content/stories/better-in-bully-batter.md
+++ b/src/content/stories/better-in-bully-batter.md
@@ -34,7 +34,7 @@ tags:
   - hyper
   - netorare
   - commission
-commissioner: scion
+commissioners: scion
 copyrightedCharacters:
   "": scion
 ---
diff --git a/src/content/stories/overzealous-zenko.md b/src/content/stories/overzealous-zenko.md
index 9123068..15e4033 100644
--- a/src/content/stories/overzealous-zenko.md
+++ b/src/content/stories/overzealous-zenko.md
@@ -28,7 +28,7 @@ tags:
   - size difference
   - implied perma endo
   - request
-requester: dee-lumeni
+requesters: dee-lumeni
 copyrightedCharacters:
   Kuronosuke: dee-lumeni
 ---
diff --git a/src/content/stories/rose-s-binge.md b/src/content/stories/rose-s-binge.md
index 2966d9a..7db3dcc 100644
--- a/src/content/stories/rose-s-binge.md
+++ b/src/content/stories/rose-s-binge.md
@@ -37,7 +37,7 @@ tags:
   - plushie
   - wardrobe malfunction
   - commission
-commissioner: dee-lumeni
+commissioners: dee-lumeni
 copyrightedCharacters:
   Rose: dee-lumeni
 ---
diff --git a/src/content/stories/team-building.md b/src/content/stories/team-building.md
index c473df6..011f668 100644
--- a/src/content/stories/team-building.md
+++ b/src/content/stories/team-building.md
@@ -34,7 +34,7 @@ tags:
   - gay sex
   - netorare
   - commission
-commissioner: yolkmonkey
+commissioners: yolkmonkey
 copyrightedCharacters:
   Yolk: yolkmonkey
 prev: team-effort
diff --git a/src/content/stories/team-effort.md b/src/content/stories/team-effort.md
index 3320ea5..a0b8747 100644
--- a/src/content/stories/team-effort.md
+++ b/src/content/stories/team-effort.md
@@ -32,7 +32,7 @@ tags:
   - inflation
   - gay sex
   - request
-requester: yolkmonkey
+requesters: yolkmonkey
 copyrightedCharacters:
   Yolk: yolkmonkey
 next: team-building
diff --git a/src/content/stories/warped-friendship.md b/src/content/stories/warped-friendship.md
index 66ec86e..4603d7d 100644
--- a/src/content/stories/warped-friendship.md
+++ b/src/content/stories/warped-friendship.md
@@ -28,7 +28,7 @@ tags:
   - same size
   - long-term endo
   - request
-requester: avour-inden
+requesters: avour-inden
 copyrightedCharacters:
   Avour: avour-inden
   Buster: holi
diff --git a/src/content/stories/within-limits.md b/src/content/stories/within-limits.md
index 1a9f50e..75b17d1 100644
--- a/src/content/stories/within-limits.md
+++ b/src/content/stories/within-limits.md
@@ -40,7 +40,7 @@ tags:
   - lesbian sex
   - orgy
   - commission
-commissioner: asof-yeun
+commissioners: asof-yeun
 copyrightedCharacters:
   Ushitora: asof-yeun
 ---
diff --git a/src/layouts/GalleryLayout.astro b/src/layouts/GalleryLayout.astro
index a18b991..6c7a928 100644
--- a/src/layouts/GalleryLayout.astro
+++ b/src/layouts/GalleryLayout.astro
@@ -40,12 +40,26 @@ const currentYear = new Date().getFullYear().toString();
       <span class="my-2 text-2xl font-semibold">Bad Manners</span>
       <Navigation />
       <div class="pt-4 text-center text-xs text-black dark:text-white">
-        <span>&copy; {currentYear == "2024" ? <time datetime="2024">2024</time> : <><time datetime="2024">2024</time>&ndash;<time datetime={currentYear}>{currentYear}</time></>} | </span>
+        <span
+          >&copy; {
+            currentYear == "2024" ? (
+              <time datetime="2024">2024</time>
+            ) : (
+              <>
+                <time datetime="2024">2024</time>&ndash;<time datetime={currentYear}>{currentYear}</time>
+              </>
+            )
+          } |
+        </span>
         <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank">Licenses</a>
       </div>
       <div class="mt-2 flex items-center gap-x-1 pb-10">
-        <a class="text-link p-1" href="https://badmanners.xyz/" target="_blank" aria-labelled-by="label-main-website">
-          <svg style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }} viewBox="0 0 576 512" aria-hidden>
+        <a class="text-link p-1" href="https://badmanners.xyz/" target="_blank" aria-labelledby="label-main-website">
+          <svg
+            style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
+            viewBox="0 0 576 512"
+            aria-hidden="true"
+          >
             <path
               d="M575.8 255.5c0 18-15 32.1-32 32.1h-32l.7 160.2c0 2.7-.2 5.4-.5 8.1V472c0 22.1-17.9 40-40 40H456c-1.1 0-2.2 0-3.3-.1c-1.4 .1-2.8 .1-4.2 .1H416 392c-22.1 0-40-17.9-40-40V448 384c0-17.7-14.3-32-32-32H256c-17.7 0-32 14.3-32 32v64 24c0 22.1-17.9 40-40 40H160 128.1c-1.5 0-3-.1-4.5-.2c-1.2 .1-2.4 .2-3.6 .2H104c-22.1 0-40-17.9-40-40V360c0-.9 0-1.9 .1-2.8V287.6H32c-18 0-32-14-32-32.1c0-9 3-17 10-24L266.4 8c7-7 15-8 22-8s15 2 21 7L564.8 231.5c8 7 12 15 11 24z"
             ></path>
@@ -53,19 +67,23 @@ const currentYear = new Date().getFullYear().toString();
           <span id="label-main-website" class="hidden">Main website</span>
         </a>
         <a class="text-link p-1" href="/feed.xml" target="_blank" aria-labelledby="label-rss-feed">
-          <svg style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }} viewBox="0 0 448 512" aria-hidden>
+          <svg
+            style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
+            viewBox="0 0 448 512"
+            aria-hidden="true"
+          >
             <path
               d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zM96 136c0-13.3 10.7-24 24-24c137 0 248 111 248 248c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-110.5-89.5-200-200-200c-13.3 0-24-10.7-24-24zm0 96c0-13.3 10.7-24 24-24c83.9 0 152 68.1 152 152c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-57.4-46.6-104-104-104c-13.3 0-24-10.7-24-24zm0 120a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
             ></path>
           </svg>
           <span id="label-rss-feed" class="hidden">RSS feed</span>
         </a>
-        <button data-dark-mode class="text-link hidden p-1" aria-labelledby="label-toggle-dark-mode" aria-hidden>
+        <button data-dark-mode class="text-link hidden p-1" aria-labelledby="label-toggle-dark-mode" aria-hidden="true">
           <svg
             style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
             viewBox="0 0 512 512"
             class="hidden dark:block"
-            aria-hidden
+            aria-hidden="true"
           >
             <path
               d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"
@@ -75,7 +93,7 @@ const currentYear = new Date().getFullYear().toString();
             style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }}
             viewBox="0 0 512 512"
             class="block dark:hidden"
-            aria-hidden
+            aria-hidden="true"
           >
             <path
               d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro
index 453ea7e..9fbc074 100644
--- a/src/layouts/GameLayout.astro
+++ b/src/layouts/GameLayout.astro
@@ -54,7 +54,7 @@ const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !ga
   />
   <Fragment slot="section-information">
     <Authors lang={props.lang}>
-      {authorsList.map((author) => <UserComponent class="p-author" user={author} lang={props.lang} />)}
+      {authorsList.map((author) => <UserComponent rel="author" class="p-author" user={author} lang={props.lang} />)}
     </Authors>
     <div id="platforms">
       <p>{t(props.lang, "game/platforms", props.platforms)}</p>
diff --git a/src/layouts/PublishedContentLayout.astro b/src/layouts/PublishedContentLayout.astro
index 473232a..399e93f 100644
--- a/src/layouts/PublishedContentLayout.astro
+++ b/src/layouts/PublishedContentLayout.astro
@@ -115,9 +115,13 @@ const thumbnail =
         <a
           href={series ? series.data.link : props.labelReturnTo.link}
           class="text-link my-1 p-2"
-          aria-labelled-by="label-return-to"
+          aria-labelledby="label-return-to"
         >
-          <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} viewBox="0 0 512 512" aria-hidden>
+          <svg
+            style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
+            viewBox="0 0 512 512"
+            aria-hidden="true"
+          >
             <path
               d="M48.5 224H40c-13.3 0-24-10.7-24-24V72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8H48.5z"
             ></path>
@@ -131,9 +135,13 @@ const thumbnail =
         <a
           href="#description"
           class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700"
-          aria-labelled-by="label-go-to-description"
+          aria-labelledby="label-go-to-description"
         >
-          <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} viewBox="0 0 512 512" aria-hidden>
+          <svg
+            style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
+            viewBox="0 0 512 512"
+            aria-hidden="true"
+          >
             <path
               d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
             ></path>
@@ -144,14 +152,14 @@ const thumbnail =
         <button
           data-dark-mode
           class="text-link my-1 hidden border-l border-stone-300 p-2 dark:border-stone-700"
-          aria-labelled-by="label-toggle-dark-mode"
-          aria-hidden
+          aria-labelledby="label-toggle-dark-mode"
+          aria-hidden="true"
         >
           <svg
             style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
             viewBox="0 0 512 512"
             class="hidden dark:block"
-            aria-hidden
+            aria-hidden="true"
           >
             <path
               d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z"
@@ -161,7 +169,7 @@ const thumbnail =
             style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
             viewBox="0 0 512 512"
             class="block dark:hidden"
-            aria-hidden
+            aria-hidden="true"
           >
             <path
               d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
@@ -190,14 +198,14 @@ const thumbnail =
                     style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
                     class="mr-1"
                     viewBox="0 0 320 512"
-                    aria-hidden
+                    aria-hidden="true"
                   >
                     <path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" />
                   </svg>
                   <span>{props.prev.title}</span>
                 </a>
               ) : (
-                <div class="h-full border-r border-stone-400 dark:border-stone-600" aria-hidden />
+                <div class="h-full border-r border-stone-400 dark:border-stone-600" aria-hidden="true" />
               )}
               {props.next ? (
                 <a
@@ -210,13 +218,13 @@ const thumbnail =
                     style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
                     class="ml-1"
                     viewBox="0 0 320 512"
-                    aria-hidden
+                    aria-hidden="true"
                   >
                     <path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" />
                   </svg>
                 </a>
               ) : (
-                <div aria-hidden />
+                <div aria-hidden="true" />
               )}
             </div>
             <hr class="mx-auto mb-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" />
@@ -277,7 +285,7 @@ const thumbnail =
           <time
             id="publish-date"
             datetime={props.pubDate.toISOString().slice(0, 10)}
-            class="dt-published block mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
+            class="dt-published mt-2 block px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
             aria-label={t(props.lang, "published_content/publish_date_aria_label")}
             aria-description={
               t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
@@ -323,7 +331,7 @@ const thumbnail =
             style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
             class="mr-1"
             viewBox="0 0 384 512"
-            aria-hidden
+            aria-hidden="true"
             ><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
@@ -345,7 +353,7 @@ const thumbnail =
                     style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
                     class="mr-1"
                     viewBox="0 0 320 512"
-                    aria-hidden
+                    aria-hidden="true"
                   >
                     <path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" />
                   </svg>
@@ -365,7 +373,7 @@ const thumbnail =
                     style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }}
                     class="ml-1"
                     viewBox="0 0 320 512"
-                    aria-hidden
+                    aria-hidden="true"
                   >
                     <path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" />
                   </svg>
@@ -441,7 +449,9 @@ const thumbnail =
       class="pt-6 text-center text-xs text-black dark:text-white"
       aria-label={t(props.lang, "published_content/copyright_aria_label")}
     >
-      <span set:html={t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())}></span><span>&nbsp;|</span>
+      <span
+        set:html={t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())}
+      /><span>&nbsp;|</span>
       <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
         >{t(props.lang, "published_content/licenses")}</a
       >
diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro
index ee3cd63..e7875d3 100644
--- a/src/layouts/StoryLayout.astro
+++ b/src/layouts/StoryLayout.astro
@@ -15,8 +15,8 @@ const prev = props.prev && (await getEntry(props.prev));
 const next = props.next && (await getEntry(props.next));
 const series = props.series && (await getEntry(props.series));
 const authorsList = await getEntries(props.authors);
-const commissionersList = props.commissioner && (await getEntries(props.commissioner));
-const requestersList = props.requester && (await getEntries(props.requester));
+const commissionersList = props.commissioners && (await getEntries(props.commissioners));
+const requestersList = props.requesters && (await getEntries(props.requesters));
 const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
 const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
 const wordCount = props.wordCount?.toString();
@@ -65,7 +65,7 @@ const wordCount = props.wordCount?.toString();
   />
   <Fragment slot="section-information">
     <Authors lang={props.lang}>
-      {authorsList.map((author) => <UserComponent class="p-author" user={author} lang={props.lang} />)}
+      {authorsList.map((author) => <UserComponent rel="author" class="p-author" user={author} lang={props.lang} />)}
     </Authors>
     {
       requestersList && (
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index 0a10199..1d70b59 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -47,8 +47,8 @@ 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);
-    const commissionersList = story.data.commissioner && (await getEntries(story.data.commissioner));
-    const requestersList = story.data.requester && (await getEntries(story.data.requester));
+    const commissionersList = story.data.commissioners && (await getEntries(story.data.commissioners));
+    const requestersList = story.data.requesters && (await getEntries(story.data.requesters));
 
     const description = await Promise.all(
       WEBSITE_LIST.map(async ({ website, exportFormat }) => {
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index 554eee0..9f4d71a 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -52,18 +52,18 @@ async function storyFeedItem(
           "story/authors",
           (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
         )}</p>` +
-        (data.requester
+        (data.requesters
           ? `<p>${t(
               data.lang,
               "story/requested_by",
-              (await getEntries(data.requester)).map((requester) => getLinkForUser(requester, data.lang)),
+              (await getEntries(data.requesters)).map((requester) => getLinkForUser(requester, data.lang)),
             )}</p>`
           : "") +
-        (data.commissioner
+        (data.commissioners
           ? `<p>${t(
               data.lang,
               "story/commissioned_by",
-              (await getEntries(data.commissioner)).map((commissioner) => getLinkForUser(commissioner, data.lang)),
+              (await getEntries(data.commissioners)).map((commissioner) => getLinkForUser(commissioner, data.lang)),
             )}</p>`
           : "") +
         `<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` +
diff --git a/src/pages/games.astro b/src/pages/games.astro
index 8ea9d46..fce5810 100644
--- a/src/pages/games.astro
+++ b/src/pages/games.astro
@@ -1,14 +1,20 @@
 ---
 import { Image } from "astro:assets";
-import { getCollection, type CollectionEntry } from "astro:content";
+import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
-import { t } from "../i18n";
+import { DEFAULT_LANG, t } from "../i18n";
+import UserComponent from "../components/UserComponent.astro";
 
 type GameWithPubDate = CollectionEntry<"games"> & { data: { pubDate: Date } };
 
-const games = (
-  (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)) as GameWithPubDate[]
-).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
+const games = await Promise.all(
+  ((await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)) as GameWithPubDate[])
+    .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+    .map(async (game) => ({
+      ...game,
+      authors: await getEntries(game.data.authors),
+    })),
+);
 ---
 
 <GalleryLayout pageTitle="Games" class="h-feed">
@@ -17,7 +23,7 @@ const games = (
   <p class="p-summary my-4">A game that I've gone and done.</p>
   <ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
-      games.map((game) => (
+      games.map((game, i) => (
         <li class="h-entry">
           <a
             class="u-url text-link hover:underline focus:underline"
@@ -26,7 +32,13 @@ const games = (
           >
             {game.data.thumbnail ? (
               <div class="flex aspect-[630/500] max-w-[288px] justify-center">
-                <Image class="u-photo m-auto" src={game.data.thumbnail} alt={`Thumbnail for ${game.data.title}`} width={288} />
+                <Image
+                  loading={i < 10 ? "eager" : "lazy"}
+                  class="u-photo m-auto"
+                  src={game.data.thumbnail}
+                  alt={`Thumbnail for ${game.data.title}`}
+                  width={288}
+                />
               </div>
             ) : null}
             <div class="max-w-[288px] text-sm">
@@ -37,6 +49,11 @@ const games = (
               </time>
             </div>
           </a>
+          <div style={{ display: "none" }}>
+            {game.authors.map((author) => (
+              <UserComponent rel="author" class="p-author" user={author} lang={DEFAULT_LANG} />
+            ))}
+          </div>
         </li>
       ))
     }
diff --git a/src/pages/games/[...slug].astro b/src/pages/games/[...slug].astro
index 4e3c03b..f5baa86 100644
--- a/src/pages/games/[...slug].astro
+++ b/src/pages/games/[...slug].astro
@@ -2,6 +2,7 @@
 import type { GetStaticPaths } from "astro";
 import { type CollectionEntry, getCollection } from "astro:content";
 import GameLayout from "../../layouts/GameLayout.astro";
+import { PUBLISH_DRAFTS } from "astro:env/server";
 
 type Props = CollectionEntry<"games">;
 
@@ -11,10 +12,12 @@ type Params = {
 
 export const getStaticPaths: GetStaticPaths = async () => {
   const games = await getCollection("games");
-  return games.map((game) => ({
-    params: { slug: game.slug } satisfies Params,
-    props: game satisfies Props,
-  }));
+  return games
+    .filter((game) => import.meta.env.DEV || PUBLISH_DRAFTS || !game.data.isDraft)
+    .map((game) => ({
+      params: { slug: game.slug } satisfies Params,
+      props: game satisfies Props,
+    }));
 };
 
 const game = Astro.props;
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 6435ada..952d61d 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,17 +1,20 @@
 ---
 import type { ImageMetadata } from "astro";
-import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content";
+import { type CollectionEntry, type CollectionKey, getCollection, getEntries } from "astro:content";
 import { Image } from "astro:assets";
 import GalleryLayout from "../layouts/GalleryLayout.astro";
-import { t } from "../i18n";
+import { DEFAULT_LANG, t, type Lang } from "../i18n";
+import UserComponent from "../components/UserComponent.astro";
 
-const MAX_ITEMS = 8;
+const MAX_ITEMS = 10;
 
 interface LatestItemsEntry {
   type: string;
   thumbnail?: ImageMetadata;
   href: string;
   title: string;
+  lang: Lang;
+  authors: CollectionEntry<"users">[];
   altText: string;
   pubDate: Date;
 }
@@ -32,27 +35,42 @@ const games = (
   .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
   .slice(0, MAX_ITEMS);
 
-const latestItems: LatestItemsEntry[] = [
-  stories.map<LatestItemsEntry>((story) => ({
-    type: "Story",
-    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),
-    pubDate: story.data.pubDate,
-  })),
-  games.map<LatestItemsEntry>((game) => ({
-    type: "Game",
-    thumbnail: game.data.thumbnail,
-    href: `/games/${game.slug}`,
-    title: game.data.title,
-    altText: t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning),
-    pubDate: game.data.pubDate,
-  })),
-]
-  .flat()
-  .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime())
-  .slice(0, MAX_ITEMS);
+const latestItems: LatestItemsEntry[] = await Promise.all(
+  [
+    stories.map((story) => ({
+      date: story.data.pubDate,
+      fn: async () =>
+        ({
+          type: "Story",
+          thumbnail: story.data.thumbnail,
+          href: `/stories/${story.slug}`,
+          title: story.data.title,
+          authors: await getEntries(story.data.authors),
+          lang: story.data.lang,
+          altText: t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning),
+          pubDate: story.data.pubDate,
+        }) satisfies LatestItemsEntry,
+    })),
+    games.map((game) => ({
+      date: game.data.pubDate,
+      fn: async () =>
+        ({
+          type: "Game",
+          thumbnail: game.data.thumbnail,
+          href: `/games/${game.slug}`,
+          title: game.data.title,
+          authors: await getEntries(game.data.authors),
+          lang: DEFAULT_LANG,
+          altText: t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning),
+          pubDate: game.data.pubDate,
+        }) satisfies LatestItemsEntry,
+    })),
+  ]
+    .flat()
+    .sort((a, b) => b.date.getTime() - a.date.getTime())
+    .slice(0, MAX_ITEMS)
+    .map(async (entry) => await entry.fn()),
+);
 ---
 
 <GalleryLayout pageTitle="Gallery" class="h-feed">
@@ -60,8 +78,8 @@ const latestItems: LatestItemsEntry[] = [
   <h1 class="p-name m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">My gallery</h1>
   <div class="p-summary">
     <p class="my-4">
-      Welcome to my gallery! You can expect lots of safe vore/endosoma ahead. Use the navigation menu to navigate through
-      my content.
+      Welcome to my gallery! You can expect lots of safe vore/endosoma ahead. Use the navigation menu to navigate
+      through my content.
     </p>
     <ul class="list-disc pl-8">
       <li><a class="text-link underline" href="/stories/1">Read my stories!</a></li>
@@ -85,7 +103,13 @@ const latestItems: LatestItemsEntry[] = [
             <a class="u-url text-link hover:underline focus:underline" href={entry.href} title={entry.altText}>
               {entry.thumbnail ? (
                 <div class="flex aspect-square max-w-[192px] justify-center">
-                  <Image class="u-photo m-auto" src={entry.thumbnail} alt={`Thumbnail for ${entry.title}`} width={192} />
+                  <Image
+                    loading="eager"
+                    class="u-photo m-auto"
+                    src={entry.thumbnail}
+                    alt={`Thumbnail for ${entry.title}`}
+                    width={192}
+                  />
                 </div>
               ) : null}
               <div class="max-w-[192px] text-sm">
@@ -93,10 +117,17 @@ const latestItems: LatestItemsEntry[] = [
                 <br />
                 <span class="italic">
                   <span class="p-category">{entry.type}</span> &ndash;{" "}
-                  <time class="dt-published" datetime={entry.pubDate.toISOString().slice(0, 10)}>{entry.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</time>
+                  <time class="dt-published" datetime={entry.pubDate.toISOString().slice(0, 10)}>
+                    {entry.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+                  </time>
                 </span>
               </div>
             </a>
+            <div style={{ display: "none" }}>
+              {entry.authors.map((author) => (
+                <UserComponent rel="author" class="p-author" user={author} lang={entry.lang} />
+              ))}
+            </div>
           </li>
         ))
       }
diff --git a/src/pages/stories/[...slug].astro b/src/pages/stories/[...slug].astro
index 51d4e58..d1834b2 100644
--- a/src/pages/stories/[...slug].astro
+++ b/src/pages/stories/[...slug].astro
@@ -3,6 +3,7 @@ import type { GetStaticPaths } from "astro";
 import { type CollectionEntry, getCollection } from "astro:content";
 import getReadingTime from "reading-time";
 import StoryLayout from "../../layouts/StoryLayout.astro";
+import { PUBLISH_DRAFTS } from "astro:env/server";
 
 type Props = CollectionEntry<"stories">;
 
@@ -12,10 +13,12 @@ type Params = {
 
 export const getStaticPaths: GetStaticPaths = async () => {
   const stories = await getCollection("stories");
-  return stories.map((story) => ({
-    params: { slug: story.slug } satisfies Params,
-    props: story satisfies Props,
-  }));
+  return stories
+    .filter((story) => import.meta.env.DEV || PUBLISH_DRAFTS || !story.data.isDraft)
+    .map((story) => ({
+      params: { slug: story.slug } satisfies Params,
+      props: story satisfies Props,
+    }));
 };
 
 const story = Astro.props;
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index a0e14da..3176da2 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -1,20 +1,26 @@
 ---
 import type { GetStaticPaths, Page } from "astro";
 import { Image } from "astro:assets";
-import { getCollection, type CollectionEntry } from "astro:content";
+import { getCollection, getEntries, type CollectionEntry } from "astro:content";
 import GalleryLayout from "../../layouts/GalleryLayout.astro";
 import { t } from "../../i18n";
+import UserComponent from "../../components/UserComponent.astro";
 
 type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } };
 
 type Props = {
-  page: Page<StoryWithPubDate>;
+  page: Page<StoryWithPubDate & { authors: CollectionEntry<"users">[] }>;
 };
 
 export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
-  const stories = (
-    (await getCollection("stories", (story) => !story.data.isDraft && story.data.pubDate)) as StoryWithPubDate[]
-  ).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
+  const stories = await Promise.all(
+    ((await getCollection("stories", (story) => !story.data.isDraft && story.data.pubDate)) as StoryWithPubDate[])
+      .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
+      .map(async (story) => ({
+        ...story,
+        authors: await getEntries(story.data.authors),
+      })),
+  );
   return paginate(stories, { pageSize: 30 }) satisfies { props: Props }[];
 };
 
@@ -44,20 +50,22 @@ const totalPages = Math.ceil(page.total / page.size);
       )
     }
     {
-      [...Array(totalPages).keys()].map((p) =>
-        p + 1 == page.currentPage ? (
-          <span class="border-r border-stone-400 px-4 py-1 font-semibold text-stone-900 dark:border-stone-500 dark:text-stone-50">
-            {p + 1}
-          </span>
-        ) : (
-          <a
-            class="text-link border-r border-stone-400 px-2 py-1 underline dark:border-stone-500"
-            href={page.url.current.replace(`/stories/${page.currentPage}`, `/stories/${p + 1}`)}
-          >
-            {p + 1}
-          </a>
-        ),
-      )
+      [...Array(totalPages).keys()]
+        .map((p) => p + 1)
+        .map((p) =>
+          p == page.currentPage ? (
+            <span class="border-r border-stone-400 px-4 py-1 font-semibold text-stone-900 dark:border-stone-500 dark:text-stone-50">
+              {p}
+            </span>
+          ) : (
+            <a
+              class="text-link border-r border-stone-400 px-2 py-1 underline dark:border-stone-500"
+              href={page.url.current.replace(`/stories/${page.currentPage}`, `/stories/${p}`)}
+            >
+              {p}
+            </a>
+          ),
+        )
     }
     {
       page.url.next && (
@@ -69,7 +77,7 @@ const totalPages = Math.ceil(page.total / page.size);
   </div>
   <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
     {
-      page.data.map((story) => (
+      page.data.map((story, i) => (
         <li class="h-entry break-inside-avoid">
           <a
             class="u-url text-link hover:underline focus:underline"
@@ -79,6 +87,7 @@ const totalPages = Math.ceil(page.total / page.size);
             {story.data.thumbnail ? (
               <div class="flex aspect-square max-w-[192px] justify-center">
                 <Image
+                  loading={i < 10 ? "eager" : "lazy"}
                   class="u-photo m-auto"
                   src={story.data.thumbnail}
                   alt={`Thumbnail for ${story.data.title}`}
@@ -94,6 +103,11 @@ const totalPages = Math.ceil(page.total / page.size);
               </time>
             </div>
           </a>
+          <div style={{ display: "none" }}>
+            {story.authors.map((author) => (
+              <UserComponent rel="author" class="p-author" user={author} lang={story.data.lang} />
+            ))}
+          </div>
         </li>
       ))
     }
@@ -107,20 +121,22 @@ const totalPages = Math.ceil(page.total / page.size);
       )
     }
     {
-      [...Array(totalPages).keys()].map((p) =>
-        p + 1 == page.currentPage ? (
-          <span class="border-r border-stone-400 px-4 py-1 font-semibold text-stone-900 dark:border-stone-500 dark:text-stone-50">
-            {p + 1}
-          </span>
-        ) : (
-          <a
-            class="text-link border-r border-stone-400 px-2 py-1 underline dark:border-stone-500"
-            href={page.url.current.replace(`/stories/${page.currentPage}`, `/stories/${p + 1}`)}
-          >
-            {p + 1}
-          </a>
-        ),
-      )
+      [...Array(totalPages).keys()]
+        .map((p) => p + 1)
+        .map((p) =>
+          p == page.currentPage ? (
+            <span class="border-r border-stone-400 px-4 py-1 font-semibold text-stone-900 dark:border-stone-500 dark:text-stone-50">
+              {p}
+            </span>
+          ) : (
+            <a
+              class="text-link border-r border-stone-400 px-2 py-1 underline dark:border-stone-500"
+              href={page.url.current.replace(`/stories/${page.currentPage}`, `/stories/${p}`)}
+            >
+              {p}
+            </a>
+          ),
+        )
     }
     {
       page.url.next && (
diff --git a/src/pages/stories/the-lost-of-the-marshes.astro b/src/pages/stories/the-lost-of-the-marshes.astro
index 93a0ca4..9155b18 100644
--- a/src/pages/stories/the-lost-of-the-marshes.astro
+++ b/src/pages/stories/the-lost-of-the-marshes.astro
@@ -27,7 +27,9 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
     content="The Lost of the Marshes || The story of Quince, Nikili, and Suu."
   />
   <h1 class="p-name m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">{series.data.name}</h1>
-  <p class="p-summary my-4">This is the main hub for the story of Quince, Nikili, and Suu, as well as all bonus content.</p>
+  <p class="p-summary my-4">
+    This is the main hub for the story of Quince, Nikili, and Suu, as well as all bonus content.
+  </p>
   <section class="my-2" aria-labelledby="main-chapters">
     <h2
       id="main-chapters"
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index 620d1f5..b8e49ce 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -155,7 +155,7 @@ const totalWorksWithTag = t(
                       day: "numeric",
                       year: "numeric",
                     })}
-                  </span>
+                  </time>
                 </div>
               </a>
             </li>