Add Mastodon links to new stories, add title texts, and improve tags

- Added Mastodon links to "Woofer Exploration" and "Rose's Binge"
- Add title texts to stories and games thumbnails on index, stories, games, and tag pages
- Add descriptions and related tags to tag pages
This commit is contained in:
Bad Manners 2024-07-23 17:02:49 -03:00
parent efcfce1e06
commit a713adc1ec
34 changed files with 493 additions and 239 deletions

View file

@ -1,13 +1,12 @@
import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import { marked, type RendererApi } from "marked";
import { decode as tinyDecode } from "tiny-decode";
import type { Lang, Website } from "../../../content/config";
import { t } from "../../../i18n";
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
interface ExportWebsiteInfo {
website: string;
website: Website;
exportFormat: "bbcode" | "markdown";
}
@ -21,37 +20,6 @@ const WEBSITE_LIST = [
type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never;
//type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionExportFormat]> ? K : never;
const bbcodeRenderer: RendererApi = {
strong: (text) => `[b]${text}[/b]`,
em: (text) => `[i]${text}[/i]`,
codespan: (code) => code,
br: () => `\n\n`,
link: (href, _, text) => `[url=${href}]${text}[/url]`,
image: (href) => `[img]${href}[/img]`,
text: (text) => text,
paragraph: (text) => `\n${text}\n`,
list: (body, ordered) => (ordered ? `\n[ol]\n${body}[/ol]\n` : `\n[ul]\n${body}[/ul]\n`),
listitem: (text) => `[li]${text}[/li]\n`,
blockquote: (quote) => `\n[quote]${quote}[/quote]\n`,
code: (code) => `\n[code]${code}[/code]\n`,
heading: (heading) => `\n${heading}\n`,
table: (header, body) => `\n[table]\n${header}${body}[/table]\n`,
tablerow: (content) => `[tr]\n${content}[/tr]\n`,
tablecell: (content, { header }) => (header ? `[th]${content}[/th]\n` : `[td]${content}[/td]\n`),
hr: () => `\n===\n`,
del: () => {
throw new Error("Not supported by bbcodeRenderer: del");
},
html: () => {
throw new Error("Not supported by bbcodeRenderer: html");
},
checkbox: () => {
throw new Error("Not supported by bbcodeRenderer: checkbox");
},
};
function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
const link = user.data.links[website];
if (link) {
@ -147,7 +115,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
if ("weasyl" in user.data.links) {
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
} else if (isPreferredWebsite(user, "furaffinity")) {
return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`;
return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
} else if (isPreferredWebsite(user, "inkbunny")) {
return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
} else if (isPreferredWebsite(user, "sofurry")) {
@ -220,47 +188,40 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
const anonymousUser = await getEntry("users", "anonymous");
const anonymousFallback = getNameForUser(anonymousUser, anonymousUser, lang);
const description: Record<ExportWebsiteName, string> = Object.fromEntries(
await Promise.all(
WEBSITE_LIST.map(async ({ website, exportFormat }) => {
const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
const storyDescription = (
[
story.data.description,
`*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`,
t(
lang,
"export_story/writing",
authorsList.map((author) => u(author)),
),
requester && t(lang, "export_story/request_for", u(requester)),
commissioner && t(lang, "export_story/commissioned_by", u(commissioner)),
...copyrightedCharacters.map(([user, characterList]) =>
characterList[0] == ""
? t(lang, "characters/all_characters_are_copyrighted_by", u(user))
: t(lang, "characters/characters_are_copyrighted_by", u(user), characterList),
),
].filter((data) => data) as string[]
)
.join("\n\n")
.replaceAll(
/\[([^\]]+)\]\((\/[^\)]+)\)/g,
(_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
);
if (exportFormat === "bbcode") {
return [
website,
tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription))
.replaceAll(/\n\n\n+/g, "\n\n")
.trim(),
];
}
if (exportFormat === "markdown") {
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
}
throw new Error(`Unhandled ExportFormat "${exportFormat}"`);
}),
),
const description = Object.fromEntries(
WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
const storyDescription = (
[
story.data.description,
`*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`,
t(
lang,
"export_story/writing",
authorsList.map((author) => u(author)),
),
requester && t(lang, "export_story/request_for", u(requester)),
commissioner && t(lang, "export_story/commissioned_by", u(commissioner)),
...copyrightedCharacters.map(([user, characterList]) =>
characterList[0] == ""
? t(lang, "characters/all_characters_are_copyrighted_by", u(user))
: t(lang, "characters/characters_are_copyrighted_by", u(user), characterList),
),
].filter((data) => data) as string[]
)
.join("\n\n")
.replaceAll(
/\[([^\]]+)\]\((\/[^\)]+)\)/g,
(_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
);
if (exportFormat === "bbcode") {
return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n").trim()];
} else if (exportFormat === "markdown") {
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
} else {
throw new Error(`Unhandled export format "${exportFormat}"`);
}
})
);
const storyHeader =

View file

@ -84,7 +84,7 @@ export const GET: APIRoute = async ({ site }) => {
pubDate: toNoonUTCDate(data.pubDate!),
link: `/games/${slug}`,
description:
`${t(data.lang, "game/platforms", data.platforms)}. ${data.contentWarning} ${data.descriptionPlaintext || data.description}`
`${t(data.lang, "game/warnings", data.platforms, data.contentWarning)} ${data.descriptionPlaintext || data.description}`
.replaceAll(/[\n ]+/g, " ")
.trim(),
categories: ["game"],
@ -104,8 +104,8 @@ export const GET: APIRoute = async ({ site }) => {
)}</p>` +
`<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
`<hr><p><em>${data.contentWarning.trim()}</em></p>` +
`<hr>${tinyDecode(await marked(body))}` +
`<hr>${tinyDecode(await marked(data.description))}`,
`<hr>${tinyDecode(marked.parse(body) as string)}` +
`<hr>${tinyDecode(marked.parse(data.description) as string)}`,
),
})),
),

View file

@ -2,6 +2,7 @@
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import GalleryLayout from "../layouts/GalleryLayout.astro";
import { t } from "../i18n";
const games = (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)).sort(
(a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime(),
@ -16,7 +17,11 @@ const games = (await getCollection("games", (game) => !game.data.isDraft && game
{
games.map((game) => (
<li>
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
<a
class="text-link hover:underline focus:underline"
href={`/games/${game.slug}`}
title={t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning.trim())}
>
{game.data.thumbnail ? (
<div class="flex aspect-[630/500] max-w-[288px] justify-center">
<Image class="m-auto" src={game.data.thumbnail} alt={`Thumbnail for ${game.data.title}`} width={288} />

View file

@ -2,6 +2,7 @@
import { type CollectionEntry, getCollection } from "astro:content";
import { Image } from "astro:assets";
import GalleryLayout from "../layouts/GalleryLayout.astro";
import { t } from "../i18n";
const MAX_ITEMS = 8;
@ -10,6 +11,7 @@ interface LatestItemsEntry {
thumbnail: CollectionEntry<"stories">["data"]["thumbnail"];
href: string;
title: string;
altText: string;
pubDate: Date;
}
@ -26,6 +28,7 @@ const latestItems: LatestItemsEntry[] = [
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.trim()),
pubDate: story.data.pubDate!,
})),
games.map<LatestItemsEntry>((game) => ({
@ -33,6 +36,7 @@ const latestItems: LatestItemsEntry[] = [
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.trim()),
pubDate: game.data.pubDate!,
})),
]
@ -63,7 +67,7 @@ const latestItems: LatestItemsEntry[] = [
{
latestItems.map((entry) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={entry.href}>
<a class="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="m-auto" src={entry.thumbnail} alt={`Thumbnail for ${entry.title}`} width={192} />

View file

@ -4,6 +4,7 @@ import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
import type { CollectionEntry } from "astro:content";
import { t } from "../../i18n";
type Props = {
page: Page<CollectionEntry<"stories">>;
@ -67,7 +68,11 @@ const totalPages = Math.ceil(page.total / page.size);
{
page.data.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
<a
class="text-link hover:underline focus:underline"
href={`/stories/${story.slug}`}
title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
>
{story.data.thumbnail ? (
<div class="flex aspect-square max-w-[192px] justify-center">
<Image

View file

@ -2,6 +2,7 @@
import { getCollection } from "astro:content";
import { slug } from "github-slugger";
import GalleryLayout from "../layouts/GalleryLayout.astro";
import { markdownToPlaintext } from "../utils/markdown_to_plaintext";
const [stories, games, tagCategories] = await Promise.all([
getCollection("stories"),
@ -30,35 +31,43 @@ const seriesCollection = await getCollection("series");
});
const uncategorizedTagsSet = new Set(tagsSet);
const categorizedTags: Array<[string, string, string[]]> = tagCategories
.sort((a, b) => a.data.index - b.data.index)
.map((category) => {
const tagList = category.data.tags.map((tag) => (typeof tag === "string" ? tag : tag["eng"]!));
tagList.forEach((tag, index) => {
if (index !== tagList.findLastIndex((val) => tag == val)) {
throw new Error(`Duplicated tag "${tag}" found in multiple tag-categories`);
}
});
return [
category.data.name,
category.id,
tagList.filter((tag) => {
if (draftOnlyTagsSet.has(tag)) {
console.log(`Omitting draft-only tag "${tag}"`);
return false;
const categorizedTags: Array<[string, string, [string, string | undefined][]]> = await Promise.all(
tagCategories
.sort((a, b) => a.data.index - b.data.index)
.map(async (category) => {
const tagList = category.data.tags.map(({ name, description }) => {
description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ").trim();
return (typeof name === "string" ? [name, description] : [name["eng"]!, description]) as [
string,
string | undefined,
];
});
tagList.forEach(([tag, _], index) => {
if (index !== tagList.findLastIndex(([otherTag, _]) => tag == otherTag)) {
throw new Error(`Duplicated tag "${tag}" found in multiple tag-categories lists`);
}
if (tagsSet.has(tag)) {
uncategorizedTagsSet.delete(tag);
}
return true;
}),
];
});
});
return [
category.data.name,
category.id,
tagList.filter(([tag, _]) => {
if (draftOnlyTagsSet.has(tag)) {
console.log(`Omitting draft-only tag "${tag}"`);
return false;
}
if (tagsSet.has(tag)) {
uncategorizedTagsSet.delete(tag);
}
return true;
}),
];
}),
);
if (uncategorizedTagsSet.size > 0) {
const tagList = [...uncategorizedTagsSet];
console.log("The following tags have no category:", tagList);
categorizedTags.push(["Uncategorized tags", "others", tagList]);
console.warn("The following tags have no category:", tagList);
// categorizedTags.push(["Uncategorized tags", "others", tagList]);
}
---
@ -92,9 +101,9 @@ if (uncategorizedTagsSet.size > 0) {
{category}
</h2>
<ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2">
{tagList.map((tag) => (
{tagList.map(([tag, description]) => (
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white">
<a class="hover:underline focus:underline" href={`/tags/${slug(tag)}`}>
<a class="hover:underline focus:underline" href={`/tags/${slug(tag)}`} title={description}>
{tag}
</a>
</li>

View file

@ -2,11 +2,16 @@
import type { GetStaticPaths } from "astro";
import { Image } from "astro:assets";
import { type CollectionEntry, getCollection } from "astro:content";
import { Markdown } from "@astropub/md";
import { slug } from "github-slugger";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
import Prose from "../../components/Prose.astro";
import { t } from "../../i18n";
type Props = {
tag: string;
description?: string;
related?: string[];
stories: CollectionEntry<"stories">[];
games: CollectionEntry<"games">[];
};
@ -16,10 +21,11 @@ type Params = {
};
export const getStaticPaths: GetStaticPaths = async () => {
const [stories, games, series] = await Promise.all([
const [stories, games, series, tagCategories] = await Promise.all([
getCollection("stories"),
getCollection("games"),
getCollection("series"),
getCollection("tag-categories"),
]);
const seriesTags = new Set(series.map((s) => s.data.name));
const tags = new Set<string>();
@ -33,12 +39,40 @@ export const getStaticPaths: GetStaticPaths = async () => {
tags.add(tag);
});
});
const tagDescriptions = Object.fromEntries(
tagCategories.flatMap((category) =>
category.data.tags.reduce(
(acc, { name, description, related }) => {
if (related) {
related = related.filter((relatedTag) => {
if (relatedTag == name) {
console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
return false;
}
if (!tags.has(relatedTag)) {
console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
return false;
}
return true;
});
}
acc.push(
typeof name === "string" ? [name, { description, related }] : [name["eng"]!, { description, related }],
);
return acc;
},
[] as [string, { description: string | undefined; related: string[] | undefined }][],
),
),
);
return [...tags]
.filter((tag) => !seriesTags.has(tag))
.map((tag) => ({
params: { slug: slug(tag) } satisfies Params,
props: {
tag,
description: tagDescriptions[tag].description,
related: tagDescriptions[tag].related,
stories: stories
.filter((story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag))
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()),
@ -49,28 +83,43 @@ export const getStaticPaths: GetStaticPaths = async () => {
}));
};
const { tag, stories, games } = Astro.props;
const { tag, description, stories, games, related } = Astro.props;
if (!description) {
console.warn(`Tag "${tag}" has no description!`);
}
const count = stories.length + games.length;
let tagDescription: string = "";
let totalWorksWithTag: string = "";
if (count == 1) {
if (stories.length == 1) {
tagDescription = `One story tagged with "${tag}".`;
totalWorksWithTag = `One story tagged with "${tag}".`;
} else if (games.length == 1) {
tagDescription = `One game tagged with "${tag}".`;
totalWorksWithTag = `One game tagged with "${tag}".`;
}
} else if (stories.length == 0) {
tagDescription = `${games.length} games tagged with "${tag}".`;
totalWorksWithTag = `${games.length} games tagged with "${tag}".`;
} else if (games.length == 0) {
tagDescription = `${stories.length} stories tagged with "${tag}".`;
totalWorksWithTag = `${stories.length} stories tagged with "${tag}".`;
} else {
tagDescription = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`;
totalWorksWithTag = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`;
}
---
<GalleryLayout pageTitle={`Works tagged "${tag}"`}>
<meta slot="head-description" content={`Bad Manners || ${tagDescription || tag}`} property="og:description" />
<meta slot="head-description" content={`Bad Manners || ${totalWorksWithTag || tag}`} property="og:description" />
<h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{tag}"</h1>
{tagDescription ? <p class="my-4">{tagDescription}</p> : null}
<div class="my-4">
<Prose>
{description ? <Markdown of={description} /> : null}
{
related?.length ? (
<p
set:html={`See also: ${related.map((relatedTag) => `<a href="/tags/${slug(relatedTag)}">${relatedTag}</a>`).join(", ")}.`}
/>
) : null
}
<p>{totalWorksWithTag}</p>
</Prose>
</div>
{
stories.length > 0 && (
<section class="my-2" aria-labelledby="content-stories">
@ -80,7 +129,11 @@ if (count == 1) {
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{stories.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
<a
class="text-link hover:underline focus:underline"
href={`/stories/${story.slug}`}
title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
>
{story.data.thumbnail ? (
<div class="flex aspect-square max-w-[192px] justify-center">
<Image
@ -108,7 +161,11 @@ if (count == 1) {
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{games.map((game) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
<a
class="text-link hover:underline focus:underline"
href={`/games/${game.slug}`}
title={t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning.trim())}
>
{game.data.thumbnail ? (
<div class="flex aspect-[630/500] max-w-[192px] justify-center">
<Image