Add description exports and collapse characters from same user

This commit is contained in:
Bad Manners 2024-03-21 22:24:58 -03:00
parent 2990644f87
commit d4a9dc9dbc
78 changed files with 693 additions and 247 deletions

View file

@ -1,36 +0,0 @@
---
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import { getUnixTime } from "date-fns";
import GalleryLayout from "../layouts/GalleryLayout.astro";
const stories = (await getCollection("stories"))
.filter((story) => !story.data.isDraft)
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
---
<GalleryLayout pageTitle="Stories">
<h1>Stories</h1>
<p>Lorem ipsum.</p>
<ul>
{
stories.map((story) => (
<li>
<a href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
<Image
src={story.data.thumbnail}
alt={`Thumbnail for ${story.data.title}`}
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
<span>
{story.data.pubDate} - {story.data.title}
</span>
</a>
</li>
))
}
</ul>
</GalleryLayout>

View file

@ -8,11 +8,11 @@ type FeedItem = RSSFeedItem & {
};
export const GET: APIRoute = async ({ site }) => {
const stories = (await getCollection("stories")).filter((story) => !story.data.isDraft);
const games = (await getCollection("games")).filter((game) => !game.data.isDraft);
const stories = await getCollection("stories", (story) => !story.data.isDraft);
const games = await getCollection("games", (game) => !game.data.isDraft);
return rss({
title: "Gallery | Bad Manners",
description: "Stories, games, and more by Bad Manners",
description: "Stories, games, and (possibly) more by Bad Manners",
site: site as URL,
items: [
stories.map<FeedItem>((story) => ({
@ -21,7 +21,7 @@ export const GET: APIRoute = async ({ site }) => {
link: `/stories/${story.slug}`,
description:
`Word count: ${story.data.wordCount}. ${story.data.contentWarning} ${story.data.descriptionPlaintext || story.data.description}`
.replaceAll(/\n+| +/g, " ")
.replaceAll(/[\n ]+/g, " ")
.trim(),
categories: ["story"],
})),
@ -30,7 +30,7 @@ export const GET: APIRoute = async ({ site }) => {
pubDate: addHours(game.data.pubDate, 12),
link: `/games/${game.slug}`,
description: `${game.data.contentWarning} ${game.data.descriptionPlaintext || game.data.description}`
.replaceAll(/\n+| +/g, " ")
.replaceAll(/[\n ]+/g, " ")
.trim(),
categories: ["game"],
})),

View file

@ -5,9 +5,9 @@ import { getUnixTime, format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import GalleryLayout from "../layouts/GalleryLayout.astro";
const games = (await getCollection("games"))
.filter((game) => !game.data.isDraft)
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
(a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate),
);
---
<GalleryLayout pageTitle="Games">
@ -18,7 +18,7 @@ const games = (await getCollection("games"))
games.map((game) => (
<li>
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
{game.data.thumbnail && (
{game.data.thumbnail ? (
<Image
class="max-w-72"
src={game.data.thumbnail}
@ -26,7 +26,7 @@ const games = (await getCollection("games"))
width={game.data.thumbnailWidth}
height={game.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-72 text-sm">
<>
<span>{game.data.title}</span>

View file

@ -1,20 +1,21 @@
---
import type { GetStaticPaths } from "astro";
import { type CollectionEntry, getCollection } from "astro:content";
import GameLayout from "../../layouts/GameLayout.astro";
export async function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = async () => {
const games = await getCollection("games");
return games.map((story) => ({
params: { slug: story.slug },
props: story,
return games.map((game) => ({
params: { slug: game.slug },
props: game,
}));
}
};
type Props = CollectionEntry<"games">;
const story = Astro.props;
const { Content } = await story.render();
const game = Astro.props;
const { Content } = await game.render();
---
<GameLayout {...story.data}>
<GameLayout {...game.data}>
<Content />
</GameLayout>

View file

@ -1,14 +1,15 @@
---
import type { GetStaticPaths } from "astro";
import { type CollectionEntry, getCollection } from "astro:content";
import StoryLayout from "../../layouts/StoryLayout.astro";
export async function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = async () => {
const stories = await getCollection("stories");
return stories.map((story) => ({
params: { slug: story.slug },
props: story,
}));
}
};
type Props = CollectionEntry<"stories">;
const story = Astro.props;

View file

@ -1,17 +1,23 @@
---
import type { GetStaticPathsOptions } from "astro";
import type { GetStaticPaths, Page } from "astro";
import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import { getUnixTime, format as formatDate } from "date-fns";
import { enUS as enUSLocale } from "date-fns/locale/en-US";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
import type { CollectionEntry } from "astro:content";
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
const stories = (await getCollection("stories"))
.filter((story) => !story.data.isDraft)
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate));
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort(
(a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate),
);
return paginate(stories, { pageSize: 30 });
}
};
type Props = {
page: Page<CollectionEntry<"stories">>;
};
const { page } = Astro.props;
const totalPages = Math.ceil(page.total / page.size);
---
@ -60,7 +66,7 @@ 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}`}>
{story.data.thumbnail && (
{story.data.thumbnail ? (
<Image
class="w-48"
src={story.data.thumbnail}
@ -68,7 +74,7 @@ const totalPages = Math.ceil(page.total / page.size);
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">
<>
<span>{story.data.title}</span>

View file

@ -0,0 +1,274 @@
import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import { marked, type RendererApi } from "marked";
import he from "he";
import { type Website } from "../../../../content/config";
const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[];
type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<infer K> ? 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) {
if (typeof link === "string") {
switch (website) {
case "website":
break;
case "eka":
const ekaMatch = link.match(/^.*\baryion\.com\/g4\/user\/([^\/]+)\/?$/);
if (ekaMatch && ekaMatch[1]) {
return ekaMatch[1];
}
break;
case "furaffinity":
const faMatch = link.match(/^.*\bfuraffinity\.net\/user\/([^\/]+)\/?$/);
if (faMatch && faMatch[1]) {
return faMatch[1];
}
break;
case "inkbunny":
const ibMatch = link.match(/^.*\binkbunny\.net\/([^\/]+)\/?$/);
if (ibMatch && ibMatch[1]) {
return ibMatch[1];
}
break;
case "sofurry":
const sfMatch = link.match(/^(?:https?:\/\/)?([^\.]+).sofurry.com\b.*$/);
if (sfMatch && sfMatch[1]) {
return sfMatch[1].replaceAll("-", " ");
}
break;
case "weasyl":
const weasylMatch = link.match(/^.*\bweasyl\.com\/\~([^\/]+)\/?$/);
if (weasylMatch && weasylMatch[1]) {
return weasylMatch[1];
}
break;
case "twitter":
const twitterMatch = link.match(/^.*(?:\btwitter\.com|\bx\.com)\/@?([^\/]+)\/?$/);
if (twitterMatch && twitterMatch[1]) {
return twitterMatch[1];
}
break;
case "mastodon":
const mastodonMatch = link.match(/^(?:https?\:\/\/)?([^\/]+)\/(?:@|users\/)([^\/]+)\/?$/);
if (mastodonMatch && mastodonMatch[1] && mastodonMatch[2]) {
return `${mastodonMatch[2]}@${mastodonMatch[1]}`;
}
break;
case "bluesky":
const bskyMatch = link.match(/^.*\bbsky\.app\/profile\/([^\/]+)\/?$/);
if (bskyMatch && bskyMatch[1]) {
return bskyMatch[1];
}
break;
default:
throw new Error(`Unhandled website "${website}" in getUsernameForWebsite`);
}
} else {
return link[1].replace(/^@/, "");
}
}
throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
}
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite): string {
switch (website) {
case "eka":
if (user.data.links.eka) {
return `:icon${getUsernameForWebsite(user, "eka")}:`;
}
break;
case "furaffinity":
if (user.data.links.furaffinity) {
return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
}
break;
case "weasyl":
if (user.data.links.weasyl) {
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
} else if (
user.data.links.furaffinity &&
!(["inkbunny", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`;
} else if (
user.data.links.inkbunny &&
!(["furaffinity", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
} else if (
user.data.links.sofurry &&
!(["furaffinity", "inkbunny"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `<sf:${getUsernameForWebsite(user, "sofurry")}>`;
}
break;
case "inkbunny":
if (user.data.links.inkbunny) {
return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
} else if (
user.data.links.furaffinity &&
!(["sofurry", "weasyl"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
} else if (
user.data.links.sofurry &&
!(["furaffinity", "weasyl"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `[sf]${getUsernameForWebsite(user, "sofurry")}[/sf]`;
} else if (
user.data.links.weasyl &&
!(["furaffinity", "sofurry"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `[weasyl]${getUsernameForWebsite(user, "weasyl")}[/weasyl]`;
}
break;
case "sofurry":
if (user.data.links.sofurry) {
return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
} else if (
user.data.links.furaffinity &&
!(["inkbunny"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
} else if (
user.data.links.inkbunny &&
!(["furaffinity"] satisfies Website[] as (Website | null)[]).includes(user.data.preferredLink)
) {
return `ib!${getUsernameForWebsite(user, "inkbunny")}`;
}
break;
default:
throw new Error(`Unhandled website "${website}" in getLinkForUser`);
}
if (user.data.preferredLink) {
if (user.data.preferredLink in user.data.links) {
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
} else {
throw new Error(`No preferredLink "${user.data.preferredLink}" for user ${user.id}`);
}
}
throw new Error(`No "${website}"-supported link for user "${user.id}" without preferredLink`);
}
type Props = {
story: CollectionEntry<"stories">;
};
type Params = {
website: ExportWebsite;
slug: CollectionEntry<"stories">["slug"];
};
export const getStaticPaths: GetStaticPaths = async () => {
if (import.meta.env.PROD) {
return [];
}
return (await getCollection("stories"))
.map((story) =>
WEBSITE_LIST.map((website) => ({
params: { website, slug: story.slug } satisfies Params,
props: { story } satisfies Props,
})),
)
.flat();
};
export const GET: APIRoute<Props, Params> = async ({ props: { story }, params: { website }, site }) => {
const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website);
if (
story.data.copyrightedCharacters &&
"" in story.data.copyrightedCharacters &&
Object.keys(story.data.copyrightedCharacters).length > 1
) {
throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys");
}
const charactersPerUser =
story.data.copyrightedCharacters &&
Object.keys(story.data.copyrightedCharacters).reduce(
(acc, character) => {
const key = story.data.copyrightedCharacters[character].id;
if (!(key in acc)) {
acc[key] = [];
}
acc[key].push(character);
return acc;
},
{} as Record<
CollectionEntry<"users">["id"],
(typeof story.data.copyrightedCharacters extends Record<infer K, any> ? K : never)[]
>,
);
let storyDescription = (
[
story.data.description,
`*Word count: ${story.data.wordCount}. ${story.data.contentWarning.trim()}*`,
"Writing: " + (await getEntries([story.data.authors].flat())).map((author) => u(author)).join(" , "),
story.data.requester && "Request for: " + u(await getEntry(story.data.requester)),
story.data.commissioner && "Commissioned by: " + u(await getEntry(story.data.commissioner)),
...(await Promise.all(
(Object.keys(charactersPerUser) as CollectionEntry<"users">["id"][]).map(async (id) => {
const user = u(await getEntry("users", id));
const characterList = charactersPerUser[id];
if (characterList[0] == "") {
return `All characters are © ${user}`;
} else if (characterList.length > 2) {
return `${characterList.slice(0, characterList.length - 1).join(", ")}, and ${characterList[characterList.length - 1]} are © ${user}`;
} else if (characterList.length > 1) {
return `${characterList[0]} and ${characterList[1]} are © ${user}`;
}
return `${characterList[0]} is © ${user}`;
}),
)),
].filter((data) => data) as string[]
)
.join("\n\n")
.replaceAll(
/\[([^\]]+)\]\((\.[^\)]+)\)/g,
(_, group1, group2) => `[${group1}](${new URL(group2, new URL(`/stories/${story.slug}`, site)).toString()})`,
);
const headers = { "Content-Type": "text/markdown; charset=utf-8" };
// BBCode exports
if ((["eka", "furaffinity", "inkbunny", "sofurry"] satisfies ExportWebsite[] as ExportWebsite[]).includes(website)) {
storyDescription = he.decode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription));
headers["Content-Type"] = "text/plain; charset=utf-8";
// Markdown exports (no-op)
} else if (!(["weasyl"] satisfies ExportWebsite[] as ExportWebsite[]).includes(website)) {
return new Response(null, { status: 404 });
}
return new Response(`${storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()}\n`, { headers });
};

View file

@ -5,7 +5,8 @@ import { getUnixTime } from "date-fns";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
import mapImage from "../../assets/images/tlotm_map.jpg";
const stories = (await getCollection("stories")).filter(
const stories = await getCollection(
"stories",
(story) => !story.data.isDraft && story.slug.startsWith("the-lost-of-the-marshes/"),
);
const mainChapters = stories
@ -50,7 +51,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
mainChapters.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
{story.data.thumbnail ? (
<Image
class="w-48"
src={story.data.thumbnail}
@ -58,7 +59,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">{story.data.title}</div>
</a>
</li>
@ -73,7 +74,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
bonusChapters.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
{story.data.thumbnail ? (
<Image
class="w-48"
src={story.data.thumbnail}
@ -81,7 +82,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">{story.data.title}</div>
</a>
</li>

View file

@ -3,47 +3,46 @@ import { getCollection } from "astro:content";
import { slug } from "github-slugger";
import GalleryLayout from "../layouts/GalleryLayout.astro";
const [stories, games] = await Promise.all([getCollection("stories"), getCollection("games")]);
const [stories, games] = await Promise.all([
getCollection("stories", (story) => !story.data.isDraft),
getCollection("games", (game) => !game.data.isDraft),
]);
const tagsSet = new Set<string>();
const seriesList: Record<string, string> = {};
stories
.filter((story) => !story.data.isDraft)
.forEach((story) => {
story.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
if (story.data.series) {
const [series, url] = Object.entries(story.data.series)[0];
if (seriesList[series]) {
if (seriesList[series] !== url) {
throw new Error(
`Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
);
}
} else {
seriesList[series] = url;
}
}
stories.forEach((story) => {
story.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
games
.filter((game) => !game.data.isDraft)
.forEach((game) => {
game.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
if (game.data.series) {
const [series, url] = Object.entries(game.data.series)[0];
if (seriesList[series]) {
if (seriesList[series] !== url) {
throw new Error(
`Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
);
}
} else {
seriesList[series] = url;
if (story.data.series) {
const [series, url] = Object.entries(story.data.series)[0];
if (seriesList[series]) {
if (seriesList[series] !== url) {
throw new Error(
`Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
);
}
} else {
seriesList[series] = url;
}
}
});
games.forEach((game) => {
game.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
if (game.data.series) {
const [series, url] = Object.entries(game.data.series)[0];
if (seriesList[series]) {
if (seriesList[series] !== url) {
throw new Error(
`Mismatched series "${series}": tried to assign different links "${seriesList[series]}" and "${url}"`,
);
}
} else {
seriesList[series] = url;
}
}
});
const categorizedTags: Record<string, string[]> = {
"Types of vore": [

View file

@ -1,11 +1,12 @@
---
import type { GetStaticPaths } from "astro";
import { Image } from "astro:assets";
import { type CollectionEntry, getCollection } from "astro:content";
import { slug } from "github-slugger";
import { getUnixTime } from "date-fns";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
export async function getStaticPaths() {
export const getStaticPaths: GetStaticPaths = async () => {
const [stories, games] = await Promise.all([getCollection("stories"), getCollection("games")]);
const tags = new Set<string>();
stories.forEach((story) => {
@ -32,7 +33,7 @@ export async function getStaticPaths() {
.sort((a, b) => getUnixTime(b.data.pubDate) - getUnixTime(a.data.pubDate)),
},
}));
}
};
type Props = {
tag: string;
@ -55,7 +56,7 @@ const { tag, stories, games } = Astro.props;
{stories.map((story) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}>
{story.data.thumbnail && (
{story.data.thumbnail ? (
<Image
class="w-48"
src={story.data.thumbnail}
@ -63,7 +64,7 @@ const { tag, stories, games } = Astro.props;
width={story.data.thumbnailWidth}
height={story.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">{story.data.title}</div>
</a>
</li>
@ -82,7 +83,7 @@ const { tag, stories, games } = Astro.props;
{games.map((game) => (
<li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}>
{game.data.thumbnail && (
{game.data.thumbnail ? (
<Image
class="w-48"
src={game.data.thumbnail}
@ -90,7 +91,7 @@ const { tag, stories, games } = Astro.props;
width={game.data.thumbnailWidth}
height={game.data.thumbnailHeight}
/>
)}
) : null}
<div class="max-w-48 text-sm">{game.data.title}</div>
</a>
</li>