From 90fc60e871aa5d63b2027829b921dc283ac61478 Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Sat, 31 Aug 2024 19:02:45 -0300
Subject: [PATCH] Update licenses and improve sr-only elements

---
 astro.config.mjs                         |   5 +-
 package-lock.json                        |   4 +-
 package.json                             |   2 +-
 src/data/licenses.toml                   |   9 +-
 src/layouts/GalleryLayout.astro          |   4 +-
 src/layouts/PublishedContentLayout.astro |   6 +-
 src/pages/games.astro                    |   2 +-
 src/pages/index.astro                    |   2 +-
 src/pages/licenses.toml.ts               | 168 ++++++++++++++---------
 src/pages/stories/[page].astro           |   2 +-
 src/pages/tags/[slug].astro              |   4 +-
 11 files changed, 126 insertions(+), 82 deletions(-)

diff --git a/astro.config.mjs b/astro.config.mjs
index 007d541..afc1300 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -14,7 +14,10 @@ export default defineConfig({
     markdownIntegration(),
     htaccessIntegration({
       generateHtaccessFile: import.meta.env.APACHE_CONFIG === "true",
-      redirects: [ { match: "/story/", url: "/stories/" }, { match: "/game/", url: "/games/" } ],
+      redirects: [
+        { match: "/story/", url: "/stories/" },
+        { match: "/game/", url: "/games/" },
+      ],
     }),
     pagefindIntegration(),
   ],
diff --git a/package-lock.json b/package-lock.json
index 9ab53c4..a0b46b9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery.badmanners.xyz",
-  "version": "1.7.11",
+  "version": "1.7.12",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery.badmanners.xyz",
-      "version": "1.7.11",
+      "version": "1.7.12",
       "hasInstallScript": true,
       "dependencies": {
         "@astrojs/check": "^0.9.2",
diff --git a/package.json b/package.json
index 24764f3..1b441c4 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery.badmanners.xyz",
   "type": "module",
-  "version": "1.7.11",
+  "version": "1.7.12",
   "scripts": {
     "postinstall": "astro sync",
     "dev": "astro dev",
diff --git a/src/data/licenses.toml b/src/data/licenses.toml
index 05531e0..fb5dc6a 100644
--- a/src/data/licenses.toml
+++ b/src/data/licenses.toml
@@ -5,7 +5,7 @@ title = "gallery.badmanners.xyz"
 description = "Bad Manners's self-hosted gallery."
 type = "website"
 date = "2024"
-author = "Bad Manners <me@badmanners.xyz>"
+author = { name = "Bad Manners", url = "https://badmanners.xyz", email = "me@badmanners.xyz>" }
 source = "https://git.badmanners.xyz/badmanners/gallery.badmanners.xyz"
 license = { name = "MIT", url = "https://opensource.org/license/mit" }
 notes = """
@@ -13,21 +13,26 @@ All rights reserved.
 The MIT License applies only to the source code; see additional copyrights for details."""
 
 [[copyright.additional]]
+type = "logo"
 notes = "The briefcase logo is copyrighted and trademarked by me. All rights reserved."
 
 [[copyright.additional]]
+type = "characters"
 notes = """
 My characters, whether directly attributed to me or unattributed, are copyrighted and trademarked by me.
 All rights reserved."""
 
 [[copyright.additional]]
+type = "content"
 description = "Content hosted on this website, i.e. the stories and game(s)."
 date = "2022-2024"
-author = "Bad Manners <me@badmanners.xyz>"
+author = { name = "Bad Manners", url = "https://badmanners.xyz", email = "me@badmanners.xyz>" }
+source = "https://git.badmanners.xyz/badmanners/gallery.badmanners.xyz/src/branch/main/src/content"
 license = { name = "CC-BY-NC-ND-4.0", url = "https://creativecommons.org/licenses/by-nc-nd/4.0/" }
 notes = "All rights reserved."
 
 [[copyright.additional]]
+type = "third-party trademarks"
 notes = """
 All third-party copyrights, trademarks, and attributed characters belong to their respective owners, \
 and I'm not affiliated with any of them."""
diff --git a/src/layouts/GalleryLayout.astro b/src/layouts/GalleryLayout.astro
index a73b22f..c82927f 100644
--- a/src/layouts/GalleryLayout.astro
+++ b/src/layouts/GalleryLayout.astro
@@ -49,8 +49,8 @@ const isCurrentRoute = (path: string) =>
         class="u-logo my-4 w-full max-w-[192px] rounded-sm border-2 border-green-950 shadow-md"
         width={192}
       />
-      <span class="p-name mt-2 mb-4 text-2xl font-semibold">Bad Manners</span>
-      <ul class="flex flex-col gap-y-2 text-left pr-8 sm:pr-2">
+      <span class="p-name mb-4 mt-2 text-2xl font-semibold">Bad Manners</span>
+      <ul class="flex flex-col gap-y-2 pr-8 text-left sm:pr-2">
         <li>
           <a class="u-url text-link group" href="https://badmanners.xyz/" data-age-restricted rel="me">
             <IconHome width="1.25rem" height="1.25rem" class="inline align-text-top" />
diff --git a/src/layouts/PublishedContentLayout.astro b/src/layouts/PublishedContentLayout.astro
index c58cbba..36a1a85 100644
--- a/src/layouts/PublishedContentLayout.astro
+++ b/src/layouts/PublishedContentLayout.astro
@@ -128,7 +128,7 @@ const thumbnail =
           aria-labelledby="label-return-to"
         >
           <IconArrowBack width="1.25rem" height="1.25rem" />
-          <span class="sr-only" id="label-return-to"
+          <span class="sr-only select-none" id="label-return-to"
             >{
               series ? t(props.lang, "published_content/return_to_series", series.data.name) : props.labelReturnTo.title
             }</span
@@ -140,7 +140,7 @@ const thumbnail =
           aria-labelledby="label-go-to-description"
         >
           <IconCircleInfo width="1.25rem" height="1.25rem" />
-          <span class="sr-only" id="label-go-to-description"
+          <span class="sr-only select-none" id="label-go-to-description"
             >{t(props.lang, "published_content/go_to_description")}</span
           >
         </a>
@@ -152,7 +152,7 @@ const thumbnail =
         >
           <IconSun width="1.25rem" height="1.25rem" class="hidden dark:block" />
           <IconMoon width="1.25rem" height="1.25rem" class="block dark:hidden" />
-          <span class="sr-only" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span>
+          <span class="sr-only select-none" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span>
         </button>
       </div>
     </div>
diff --git a/src/pages/games.astro b/src/pages/games.astro
index 8554bea..bb766a0 100644
--- a/src/pages/games.astro
+++ b/src/pages/games.astro
@@ -55,7 +55,7 @@ const games = await Promise.all(
               </time>
             </div>
           </a>
-          <div class="sr-only">
+          <div class="sr-only select-none">
             <p class="p-category" aria-label="Category">
               Game
             </p>
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 821c381..e054bb5 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -133,7 +133,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
                 </span>
               </div>
             </a>
-            <div class="sr-only">
+            <div class="sr-only select-none">
               <p class="p-summary" aria-label="Summary">
                 {entry.altText}
               </p>
diff --git a/src/pages/licenses.toml.ts b/src/pages/licenses.toml.ts
index 242fa52..ec21dbd 100644
--- a/src/pages/licenses.toml.ts
+++ b/src/pages/licenses.toml.ts
@@ -3,71 +3,88 @@ import { readFile } from "node:fs/promises";
 import { parse } from "toml";
 
 /**
- * Verify attributions and copyright according to the [Creative Commons recommended practices](https://wiki.creativecommons.org/wiki/Recommended_practices_for_attribution)
+ * Makes sure the copyright follows the TASL format. T = title (or description), A = author, S = source, L = license.
+ * @param copyright
+ */
+function validateTASL(copyright: any) {
+  const title = copyright.title ?? copyright.description;
+  if (typeof title !== "string" || !title) {
+    throw new Error(`Missing "title" and/or "description" for attribution (${JSON.stringify(copyright)})`);
+  }
+  // Author must be a valid string or object or list
+  const authors = [copyright.author].flat();
+  if (authors.length === 0) {
+    throw new Error(`Missing "author" for attribution "${title}" (${JSON.stringify(copyright)})`);
+  }
+  authors.forEach((author) => {
+    if (!author) {
+      throw new Error(`Missing "author" for attribution "${title}" (${JSON.stringify(copyright)})`);
+    }
+    if (typeof author !== "object" && typeof author !== "string") {
+      throw new Error(
+        `Invalid "${typeof author}" type for "author"${JSON.stringify(author)} for attribution "${title}" (${JSON.stringify(copyright)})`,
+      );
+    }
+    if (typeof author === "object" && !(author.name || author.url)) {
+      throw new Error(
+        `Missing both name and URL for "author" ${JSON.stringify(author)} for attribution "${title}" (${JSON.stringify(copyright)})`,
+      );
+    }
+  });
+  // Source must be a valid string or list of strings
+  const sources = [copyright.source].flat();
+  if (sources.length === 0) {
+    throw new Error(`Missing "source" for attribution "${title}" (${JSON.stringify(copyright)})`);
+  }
+  sources.forEach((source) => {
+    if (!source) {
+      throw new Error(`Missing "source" for attribution "${title}" (${JSON.stringify(copyright)})`);
+    }
+    if (typeof source !== "object" && typeof source !== "string") {
+      throw new Error(
+        `Invalid "${typeof source}" type for "source"${JSON.stringify(source)} for attribution "${title}" (${JSON.stringify(copyright)})`,
+      );
+    }
+    if (typeof source === "object" && !source.url) {
+      throw new Error(
+        `Missing URL for "source" ${JSON.stringify(source)} for attribution "${title}" (${JSON.stringify(copyright)})`,
+      );
+    }
+  });
+  // License must be a valid string or object or list
+  const licenses = [copyright.license].flat();
+  if (licenses.length === 0) {
+    throw new Error(`Missing "license" for attribution "${title}" (${JSON.stringify(copyright)})`);
+  }
+  licenses.forEach((license) => {
+    if (!license) {
+      throw new Error(`Missing "license" for attribution "${title}" (${JSON.stringify(copyright)})`);
+    }
+    if (typeof license !== "object" && typeof license !== "string") {
+      throw new Error(
+        `Invalid "${typeof license}" type for "license"${JSON.stringify(license)} for attribution "${title}" (${JSON.stringify(copyright)})`,
+      );
+    }
+    if (typeof license === "object" && !(license.name || license.url)) {
+      throw new Error(
+        `Missing both name and URL for "license" ${JSON.stringify(license)} for attribution "${title}" (${JSON.stringify(copyright)})`,
+      );
+    }
+  });
+}
+
+/**
+ * Verifies attributions and copyright according to the [Creative Commons recommended practices](https://wiki.creativecommons.org/wiki/Recommended_practices_for_attribution)
  * @param copyright Unparsed TOML copyright information.
  */
 function verifyAttributions(licenses: string) {
   const { copyright, attributions } = parse(licenses);
-  // Make sure each copyright and attribution follows the TASL format.
-  // - T: title (or description)
-  // - A: author
-  // - S: source
-  // - L: license
-  // - other fields that have custom validation: type, notes, items
+  // Make sure each copyright and attribution follows the TASL format,
+  // and that other fields (type, notes, items) pass their custom validation.
   [copyright, attributions].flat().forEach((value) => {
-    // Title or description must be a valid string
-    const title = value.title ?? value.description;
-    if (typeof title !== "string" || !title) {
-      throw new Error(`Missing "title" and/or "description" for attribution (${JSON.stringify(value)})`);
-    }
-    // Author must be a valid string or object or list
-    const authors = [value.author].flat();
-    if (authors.length === 0) {
-      throw new Error(`Missing "author" for attribution "${title}" (${JSON.stringify(value)})`);
-    }
-    authors.forEach((author) => {
-      if (!author) {
-        throw new Error(`Missing "author" for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-      if (typeof author !== "object" && typeof author !== "string") {
-        throw new Error(`Invalid "${typeof author}" type for "author"${JSON.stringify(author)} for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-      if (typeof author === "object" && !(author.name || author.url)) {
-        throw new Error(`Missing both name and URL for "author" ${JSON.stringify(author)} for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-    });
-    // Source must be a valid string or list of strings
-    const sources = [value.source].flat();
-    if (sources.length === 0) {
-      throw new Error(`Missing "source" for attribution "${title}" (${JSON.stringify(value)})`);
-    }
-    sources.forEach((source) => {
-      if (!source) {
-        throw new Error(`Missing "source" for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-      if (typeof source !== "object" && typeof source !== "string") {
-        throw new Error(`Invalid "${typeof source}" type for "source"${JSON.stringify(source)} for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-      if (typeof source === "object" && !(source.url)) {
-        throw new Error(`Missing URL for "source" ${JSON.stringify(source)} for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-    });
-    // License must be a valid string or object or list
-    const licenses = [value.license].flat();
-    if (licenses.length === 0) {
-      throw new Error(`Missing "license" for attribution "${title}" (${JSON.stringify(value)})`);
-    }
-    licenses.forEach((license) => {
-      if (!license) {
-        throw new Error(`Missing "license" for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-      if (typeof license !== "object" && typeof license !== "string") {
-        throw new Error(`Invalid "${typeof license}" type for "license"${JSON.stringify(license)} for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-      if (typeof license === "object" && !(license.name || license.url)) {
-        throw new Error(`Missing both name and URL for "license" ${JSON.stringify(license)} for attribution "${title}" (${JSON.stringify(value)})`);
-      }
-    });
+    // Validate TASL
+    validateTASL(value);
+    const title = copyright.title ?? copyright.description;
     // Validate extra optional fields
     // 1. Type must be a valid string
     if (typeof value.type !== "string") {
@@ -80,13 +97,17 @@ function verifyAttributions(licenses: string) {
     if ("items" in value) {
       const items = value.items;
       if (!Array.isArray(items)) {
-        throw new Error(`Invalid non-array "items" ${JSON.stringify(items)} for attribution "${title}" (${JSON.stringify(value)})`);
+        throw new Error(
+          `Invalid non-array "items" ${JSON.stringify(items)} for attribution "${title}" (${JSON.stringify(value)})`,
+        );
       }
       items.forEach((item) => {
         if (!item) {
-          throw new Error(`Invalid item ${JSON.stringify} in "items" for attribution "${title}" (${JSON.stringify(value)})`);
+          throw new Error(
+            `Invalid item ${JSON.stringify} in "items" for attribution "${title}" (${JSON.stringify(value)})`,
+          );
         }
-      })
+      });
     }
     // 3. Type must be a valid string
     if ("notes" in value) {
@@ -106,18 +127,33 @@ function verifyAttributions(licenses: string) {
   if ("additional" in copyright) {
     const additionals = copyright.additional;
     if (!Array.isArray(additionals)) {
-      throw new Error(`Invalid non-array "additional" ${JSON.stringify(additionals)} for copyright (${JSON.stringify(copyright)})`);
+      throw new Error(
+        `Invalid non-array "additional" ${JSON.stringify(additionals)} for copyright (${JSON.stringify(copyright)})`,
+      );
     }
     additionals.forEach((additional) => {
       if (typeof additional.notes !== "string" || !additional.notes) {
         throw new Error(`Invalid "notes" for additional copyright (${JSON.stringify(additional)})`);
       }
-    })
+      if (typeof additional.type !== "string" || !additional.type) {
+        throw new Error(`Invalid "type" for additional copyright (${JSON.stringify(additional)})`);
+      }
+      // Check TASL + date if title or description is present
+      if (additional.title || additional.description) {
+        validateTASL(additional);
+        if (typeof additional.date !== "string") {
+          throw new Error(`Invalid "date" for additional (${JSON.stringify(additional)})`);
+        }
+        if (!additional.date) {
+          throw new Error(`Missing "date" for additional (${JSON.stringify(additional)})`);
+        }
+      }
+    });
   }
 }
 
 export const GET: APIRoute = async () => {
   const licenses = await readFile("./src/data/licenses.toml", { encoding: "utf-8" });
   verifyAttributions(licenses);
-  return new Response(licenses, { headers: { "Content-Type": "application/toml; charset=utf-8" } })
+  return new Response(licenses, { headers: { "Content-Type": "application/toml; charset=utf-8" } });
 };
diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro
index 3fdb804..b8910c3 100644
--- a/src/pages/stories/[page].astro
+++ b/src/pages/stories/[page].astro
@@ -113,7 +113,7 @@ const totalPages = Math.ceil(page.total / page.size);
               </time>
             </div>
           </a>
-          <div class="sr-only">
+          <div class="sr-only select-none">
             <p class="p-category" aria-label="Category">
               Story
             </p>
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index 4f9dea6..0a92916 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -177,7 +177,7 @@ const totalWorksWithTag = t(
                   </time>
                 </div>
               </a>
-              <div class="sr-only">
+              <div class="sr-only select-none">
                 <p class="p-category" aria-label="Category">
                   Story
                 </p>
@@ -235,7 +235,7 @@ const totalWorksWithTag = t(
                   </time>
                 </div>
               </a>
-              <div class="sr-only">
+              <div class="sr-only select-none">
                 <p class="p-category" aria-label="Category">
                   Game
                 </p>