From fb30f1b4164a71d8a69502bef0596f1f5a5b5404 Mon Sep 17 00:00:00 2001
From: Bad Manners <me@badmanners.xyz>
Date: Mon, 26 Aug 2024 14:53:09 -0300
Subject: [PATCH] Use history.replaceState ageVerified query and improve
 export-story script

---
 package-lock.json                             |   4 +-
 package.json                                  |   2 +-
 scripts/export-story.ts                       | 101 +++++++++++-------
 src/components/AgeRestrictedModal.astro       |   8 +-
 .../AgeRestrictedScriptInline.astro           |   2 +-
 src/components/DarkModeScript.astro           |   8 +-
 src/components/DarkModeScriptInline.astro     |   2 +-
 src/components/MastodonComments.astro         |   8 +-
 src/layouts/PublishedContentLayout.astro      |   4 +-
 src/pages/404.astro                           |   2 +-
 src/pages/api/export-story/[...slug].ts       |  12 ++-
 src/pages/api/healthcheck.ts                  |   6 +-
 src/pages/feed.xml.ts                         |   2 +
 src/pages/tags.astro                          |   6 +-
 src/pages/tags/[slug].astro                   |   6 +-
 src/utils/parse_partial_html_tag.ts           |   6 +-
 16 files changed, 96 insertions(+), 83 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 8204d38..e7f2c52 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "gallery.badmanners.xyz",
-  "version": "1.7.7",
+  "version": "1.7.8",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "gallery.badmanners.xyz",
-      "version": "1.7.7",
+      "version": "1.7.8",
       "hasInstallScript": true,
       "dependencies": {
         "@astrojs/check": "^0.9.2",
diff --git a/package.json b/package.json
index ea7cd19..e1f2954 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "gallery.badmanners.xyz",
   "type": "module",
-  "version": "1.7.7",
+  "version": "1.7.8",
   "scripts": {
     "postinstall": "astro sync",
     "dev": "astro dev",
diff --git a/scripts/export-story.ts b/scripts/export-story.ts
index 9987fdc..c92174d 100644
--- a/scripts/export-story.ts
+++ b/scripts/export-story.ts
@@ -1,10 +1,13 @@
 import { spawn, spawnSync } from "node:child_process";
-import { readdir, mkdir, mkdtemp, writeFile, readFile, copyFile } from "node:fs/promises";
+import { readdirSync, mkdirSync } from "node:fs";
+import { mkdtemp, writeFile, readFile, copyFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join, normalize } from "node:path";
 import { createInterface } from "node:readline";
 import { program } from "commander";
 import fetchRetryWrapper from "fetch-retry";
+import type { HealthcheckResponse } from "../src/pages/api/healthcheck";
+import type { ExportStoryResponse } from "../src/pages/api/export-story/[...slug]";
 
 function getRTFStyles(rtfSource: string) {
   const matches = rtfSource.matchAll(
@@ -53,10 +56,10 @@ async function exportStory(slug: string, options: { outputDir: string }) {
   const outputDir = normalize(options.outputDir);
   let files: string[];
   try {
-    files = await readdir(outputDir);
+    files = readdirSync(outputDir);
   } catch {
     files = [];
-    console.log(`Created directory at ${await mkdir(outputDir, { recursive: true })}`);
+    console.log(`Created directory at ${mkdirSync(outputDir, { recursive: true })}`);
   }
   if (files.length > 0) {
     console.error(`ERROR: Directory ${outputDir} is not empty!`);
@@ -84,7 +87,7 @@ async function exportStory(slug: string, options: { outputDir: string }) {
     if (!response.ok) {
       throw new Error(response.statusText);
     }
-    const healthcheck: { isAlive: boolean } = await response.json();
+    const healthcheck: HealthcheckResponse = await response.json();
     if (healthcheck.isAlive !== true) {
       throw new Error(JSON.stringify(healthcheck));
     }
@@ -102,32 +105,41 @@ async function exportStory(slug: string, options: { outputDir: string }) {
     console.log("Getting data from Astro...");
     const response = await fetch(new URL(`/api/export-story/${slug}`, astroURL));
     if (!response.ok) {
-      throw new Error(`Failed to reach API (status code ${response.status})`);
+      throw new Error(`Failed to reach export-story API (status code ${response.status})`);
     }
-    const data: { story: string; description: Record<string, string>; thumbnail: string | null } =
-      await response.json();
+    const data: ExportStoryResponse = await response.json();
+    // Process response fields in parallel
     await Promise.all(
-      Object.entries(data.description).map(async ([filename, description]) => {
-        return await writeFile(join(outputDir, filename), description);
-      }),
+      [
+        // Story
+        (async () => {
+          storyText = data.story;
+          await writeFile(join(outputDir, `${slug}.txt`), storyText);
+        })(),
+        // Descriptions
+        Object.entries(data.description).map(
+          async ([filename, description]) => await writeFile(join(outputDir, filename), description),
+        ),
+        // Thumbnail
+        (async () => {
+          if (data.thumbnail) {
+            if (data.thumbnail.startsWith("/@fs/")) {
+              const thumbnailPath = data.thumbnail
+                .replace(/^\/@fs/, "")
+                .replace(/\?(&?[a-z][a-zA-Z0-9_-]+=[a-zA-Z0-9_-]*)*$/, "");
+              await copyFile(thumbnailPath, join(outputDir, `thumbnail${thumbnailPath.match(/\.[^.]+$/)![0]}`));
+            } else {
+              const thumbnail = await fetchRetry(data.thumbnail, { retries: 2, retryDelay: 10000 });
+              if (!thumbnail.ok) {
+                throw new Error("Failed to get thumbnail");
+              }
+              const thumbnailExt = thumbnail.headers.get("Content-Type")!.startsWith("image/png") ? "png" : "jpg";
+              await writeFile(join(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer()));
+            }
+          }
+        })(),
+      ].flat(),
     );
-    if (data.thumbnail) {
-      if (data.thumbnail.startsWith("/@fs/")) {
-        const thumbnailPath = data.thumbnail
-          .replace(/^\/@fs/, "")
-          .replace(/\?(&?[a-z][a-zA-Z0-9_-]+=[a-zA-Z0-9_-]*)*$/, "");
-        await copyFile(thumbnailPath, join(outputDir, `thumbnail${thumbnailPath.match(/\.[^.]+$/)![0]}`));
-      } else {
-        const thumbnail = await fetch(data.thumbnail);
-        if (!thumbnail.ok) {
-          throw new Error("Failed to get thumbnail");
-        }
-        const thumbnailExt = thumbnail.headers.get("Content-Type")!.startsWith("image/png") ? "png" : "jpg";
-        await writeFile(join(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer()));
-      }
-    }
-    storyText = data.story;
-    writeFile(join(outputDir, `${slug}.txt`), storyText);
   } finally {
     if (devServerProcess) {
       console.log("Shutting down the Astro development server...");
@@ -139,18 +151,29 @@ async function exportStory(slug: string, options: { outputDir: string }) {
 
   /* Parse story into output formats */
   console.log("Parsing story into output formats...");
-  await writeFile(join(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*"));
-  const tempDir = await mkdtemp(join(tmpdir(), "export-story-"));
-  await writeFile(join(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n"));
-  spawnSync("libreoffice", ["--convert-to", "rtf:Rich Text Format", "--outdir", tempDir, join(tempDir, "temp.txt")], {
-    stdio: "ignore",
-  });
-  const rtfText = await readFile(join(tempDir, "temp.rtf"), "utf-8");
-  const rtfStyles = getRTFStyles(rtfText);
-  await writeFile(
-    join(outputDir, `${slug}.rtf`),
-    rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]),
-  );
+  // Process output files in parallel
+  await Promise.all([
+    // ${slug}.md
+    writeFile(join(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*")),
+    // ${slug}.rtf
+    (async () => {
+      const tempDir = await mkdtemp(join(tmpdir(), "export-story-"));
+      await writeFile(join(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n"));
+      spawnSync(
+        "libreoffice",
+        ["--convert-to", "rtf:Rich Text Format", "--outdir", tempDir, join(tempDir, "temp.txt")],
+        {
+          stdio: "ignore",
+        },
+      );
+      const rtfText = await readFile(join(tempDir, "temp.rtf"), "utf-8");
+      const rtfStyles = getRTFStyles(rtfText);
+      await writeFile(
+        join(outputDir, `${slug}.rtf`),
+        rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]),
+      );
+    })(),
+  ]);
   console.log("Success!");
   process.exit(0);
 }
diff --git a/src/components/AgeRestrictedModal.astro b/src/components/AgeRestrictedModal.astro
index f7feefd..82f44ba 100644
--- a/src/components/AgeRestrictedModal.astro
+++ b/src/components/AgeRestrictedModal.astro
@@ -53,7 +53,6 @@ import { IconTriangleExclamation } from "./icons";
 <AgeRestrictedScriptInline />
 
 <script>
-  const ENABLE_VIEW_TRANSITIONS = false;
   type AgeVerified = "true" | undefined;
 
   const ageRestrictedModalSetup = () => {
@@ -97,9 +96,6 @@ import { IconTriangleExclamation } from "./icons";
       rejectButton.focus();
     }
   };
-  if (ENABLE_VIEW_TRANSITIONS) {
-    document.addEventListener("astro:page-load", ageRestrictedModalSetup);
-  } else {
-    ageRestrictedModalSetup();
-  }
+
+  ageRestrictedModalSetup();
 </script>
diff --git a/src/components/AgeRestrictedScriptInline.astro b/src/components/AgeRestrictedScriptInline.astro
index ac1696a..31f37f2 100644
--- a/src/components/AgeRestrictedScriptInline.astro
+++ b/src/components/AgeRestrictedScriptInline.astro
@@ -1,4 +1,4 @@
 ---
 ---
 
-<script is:inline>function a(){let b=document,c="#modal-age-restricted",d="true",e=b.querySelector("body > "+c),f="ageVerified",g="searchParams",h=localStorage;new URL(b.location)[g].get(f)===d&&(h[f]=d);e&&(h[f]===d?b.querySelectorAll("a[href][data-age-restricted]").forEach(x=>{let y=new URL(x.href);y[g].set(f,d);x.href=y.href}):((b.body.style.overflow="hidden"),b.querySelectorAll("body > :not("+c+")").forEach(x=>x.setAttribute("inert",d)),(e.style.display="block")))};document.addEventListener("astro:after-swap",a);a()</script>
+<script is:inline>(function (){let b=document,c="#modal-age-restricted",d="true",e=b.querySelector("body > "+c),f="ageVerified",g="searchParams",h=localStorage,i=new URL(b.location),j=history;i[g].get(f)===d&&(h[f]=d,j&&(i[g].delete(f),j.replaceState({},"",i)));e&&(h[f]===d?b.querySelectorAll("a[href][data-age-restricted]").forEach(x=>{let y=new URL(x.href);y[g].set(f,d);x.href=y.href}):((b.body.style.overflow="hidden"),b.querySelectorAll("body > :not("+c+")").forEach(x=>x.setAttribute("inert",d)),(e.style.display="block")))})()</script>
diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro
index f1160a6..95e458c 100644
--- a/src/components/DarkModeScript.astro
+++ b/src/components/DarkModeScript.astro
@@ -5,7 +5,6 @@ import DarkModeScriptInline from "./DarkModeScriptInline.astro";
 <DarkModeScriptInline />
 
 <script>
-  const ENABLE_VIEW_TRANSITIONS = false;
   type ColorScheme = "auto" | "dark" | "light" | undefined;
 
   const colorSchemeSetup = () => {
@@ -31,9 +30,6 @@ import DarkModeScriptInline from "./DarkModeScriptInline.astro";
       button.setAttribute("aria-hidden", "false");
     });
   };
-  if (ENABLE_VIEW_TRANSITIONS) {
-    document.addEventListener("astro:page-load", colorSchemeSetup);
-  } else {
-    colorSchemeSetup();
-  }
+
+  colorSchemeSetup();
 </script>
diff --git a/src/components/DarkModeScriptInline.astro b/src/components/DarkModeScriptInline.astro
index f7d188b..4f017bb 100644
--- a/src/components/DarkModeScriptInline.astro
+++ b/src/components/DarkModeScriptInline.astro
@@ -1,4 +1,4 @@
 ---
 ---
 
-<script is:inline>function a(){var b="dark",c="auto",d="colorScheme",e=document.body.classList,f=localStorage,g=f&&f[d];g&&g!==c?g===b&&e.add(b):(f&&(f[d]=c),matchMedia("(prefers-color-scheme: dark)").matches&&e.add(b))};document.addEventListener("astro:after-swap",a);a()</script>
+<script is:inline>(function (){var b="dark",c="auto",d="colorScheme",e=document.body.classList,f=localStorage,g=f&&f[d];g&&g!==c?g===b&&e.add(b):(f&&(f[d]=c),matchMedia("(prefers-color-scheme: dark)").matches&&e.add(b))})()</script>
diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro
index 3deb3f8..b81d7b2 100644
--- a/src/components/MastodonComments.astro
+++ b/src/components/MastodonComments.astro
@@ -123,8 +123,6 @@ const { link, instance, user, postId } = Astro.props;
 </template>
 
 <script>
-  const ENABLE_VIEW_TRANSITIONS = false;
-
   interface MastodonPost {
     link: string;
     instance: string;
@@ -322,9 +320,5 @@ const { link, instance, user, postId } = Astro.props;
     loadCommentsButton.style.removeProperty("display");
   }
 
-  if (ENABLE_VIEW_TRANSITIONS) {
-    document.addEventListener("astro:page-load", initCommentSection);
-  } else {
-    initCommentSection();
-  }
+  initCommentSection();
 </script>
diff --git a/src/layouts/PublishedContentLayout.astro b/src/layouts/PublishedContentLayout.astro
index e6993d5..c58cbba 100644
--- a/src/layouts/PublishedContentLayout.astro
+++ b/src/layouts/PublishedContentLayout.astro
@@ -93,9 +93,7 @@ const thumbnail =
 
 <BaseLayout pageTitle={props.title} lang={props.lang}>
   <Fragment slot="head">
-    { props.isDraft ? (
-      <meta name="robots" content="noindex" />
-    ) : null }
+    {props.isDraft ? <meta name="robots" content="noindex" /> : null}
     <meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
     <meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" />
     {
diff --git a/src/pages/404.astro b/src/pages/404.astro
index fbdaa0f..12a22ab 100644
--- a/src/pages/404.astro
+++ b/src/pages/404.astro
@@ -5,5 +5,5 @@ import GalleryLayout from "../layouts/GalleryLayout.astro";
 <GalleryLayout pageTitle="404">
   <meta slot="head" property="og:description" content="Not found" />
   <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">404 &ndash; Not Found</h1>
-  <p class="my-4">The requested link couldn't be found. Make sure that the URL is correct.</p>
+  <p class="my-4">The requested link could not be found. Make sure that the URL is correct.</p>
 </GalleryLayout>
diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts
index 8179e4c..a646333 100644
--- a/src/pages/api/export-story/[...slug].ts
+++ b/src/pages/api/export-story/[...slug].ts
@@ -25,6 +25,12 @@ const WEBSITE_LIST = [
 
 type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never;
 
+export type ExportStoryResponse = {
+  story: string;
+  description: Record<string, string>;
+  thumbnail: string | null;
+};
+
 type Props = {
   story: CollectionEntry<"stories">;
 };
@@ -98,7 +104,9 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
           case "markdown":
             return {
               descriptionFilename: `description_${exportWebsite}.md`,
-              descriptionText: toPlainMarkdown(storyDescription).replaceAll(/\n\n\n+/g, "\n\n").trim(),
+              descriptionText: toPlainMarkdown(storyDescription)
+                .replaceAll(/\n\n\n+/g, "\n\n")
+                .trim(),
             };
           default:
             const unknown: never = exportFormat;
@@ -144,7 +152,7 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
           {} as Record<string, string>,
         ),
         thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
-      }),
+      } satisfies ExportStoryResponse),
       { headers: { "Content-Type": "application/json; charset=utf-8" } },
     );
   } catch (e) {
diff --git a/src/pages/api/healthcheck.ts b/src/pages/api/healthcheck.ts
index 6f2f3c4..66ac46a 100644
--- a/src/pages/api/healthcheck.ts
+++ b/src/pages/api/healthcheck.ts
@@ -1,10 +1,14 @@
 import type { APIRoute } from "astro";
 
+export type HealthcheckResponse = {
+  isAlive: true;
+};
+
 export const GET: APIRoute = () => {
   if (import.meta.env.PROD) {
     return new Response(null, { status: 404 });
   }
-  return new Response(JSON.stringify({ isAlive: true }), {
+  return new Response(JSON.stringify({ isAlive: !false } satisfies HealthcheckResponse), {
     headers: { "Content-Type": "application/json; charset=utf-8" },
   });
 };
diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts
index 9f4d71a..886b7ee 100644
--- a/src/pages/feed.xml.ts
+++ b/src/pages/feed.xml.ts
@@ -45,6 +45,7 @@ async function storyFeedItem(
         " ",
       ),
     categories: ["story"],
+    commentsUrl: data.posts.mastodon?.link,
     content: sanitizeHtml(
       `<h1>${data.title}</h1>` +
         `<p>${t(
@@ -89,6 +90,7 @@ async function gameFeedItem(
         " ",
       ),
     categories: ["game"],
+    commentsUrl: data.posts.mastodon?.link,
     content: sanitizeHtml(
       `<h1>${data.title}</h1>` +
         `<p>${t(
diff --git a/src/pages/tags.astro b/src/pages/tags.astro
index 93b952d..93eb9df 100644
--- a/src/pages/tags.astro
+++ b/src/pages/tags.astro
@@ -70,11 +70,7 @@ if (uncategorizedTagsSet.size > 0) {
 ---
 
 <GalleryLayout pageTitle="Tags">
-  <meta
-    property="og:description"
-    slot="head"
-    content="Bad Manners || Find all content with a specific tag."
-  />
+  <meta property="og:description" slot="head" content="Bad Manners || Find all content with a specific tag." />
   <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">All available tags</h1>
   <p class="my-4">You can find all content with a specific tag by selecting it below from the appropriate category.</p>
   <section class="my-2" aria-labelledby="category-series">
diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro
index e871ad9..099b5a9 100644
--- a/src/pages/tags/[slug].astro
+++ b/src/pages/tags/[slug].astro
@@ -120,11 +120,7 @@ const totalWorksWithTag = t(
 ---
 
 <GalleryLayout pageTitle={`Works tagged "${props.tag}"`}>
-  <meta
-    slot="head"
-    content={`Bad Manners || ${totalWorksWithTag || props.tag}`}
-    property="og:description"
-  />
+  <meta slot="head" content={`Bad Manners || ${totalWorksWithTag || props.tag}`} property="og:description" />
   <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{props.tag}"</h1>
   <div class="my-4">
     <Prose>
diff --git a/src/utils/parse_partial_html_tag.ts b/src/utils/parse_partial_html_tag.ts
index 679390b..7727190 100644
--- a/src/utils/parse_partial_html_tag.ts
+++ b/src/utils/parse_partial_html_tag.ts
@@ -1,7 +1,7 @@
 interface ParsedHTMLTag {
   tag: string;
   type: "open" | "close" | "both";
-  attributes?: Record<string, string|null>;
+  attributes?: Record<string, string | null>;
 }
 
 const OPEN_TAG_START_REGEX = /^<\s*([a-z-]+)\s*/;
@@ -46,8 +46,8 @@ export function parsePartialHTMLTag(text: string): ParsedHTMLTag {
       return {
         tag: closeTag[1],
         type: "close",
-      }
+      };
     }
   }
   throw new Error(`Unable to parse partial HTML tag: ${text}`);
-}
\ No newline at end of file
+}