347 lines
13 KiB
TypeScript
347 lines
13 KiB
TypeScript
import { type APIRoute, type 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, type Website } from "../../../content/config";
|
|
|
|
type DescriptionFormat = "bbcode" | "markdown";
|
|
|
|
const WEBSITE_LIST = [
|
|
["eka", "bbcode"],
|
|
["furaffinity", "bbcode"],
|
|
["inkbunny", "bbcode"],
|
|
["sofurry", "bbcode"],
|
|
["weasyl", "markdown"],
|
|
] as const satisfies [Website, DescriptionFormat][];
|
|
|
|
type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionFormat]> ? 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;
|
|
case "itaku":
|
|
const itakuMatch = link.match(/^.*\bitaku\.ee\/profile\/([^\/]+)\/?$/);
|
|
if (itakuMatch && itakuMatch[1]) {
|
|
return itakuMatch[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 isPreferredWebsite(
|
|
user: CollectionEntry<"users">,
|
|
website: Website,
|
|
preferredChoices: readonly Website[],
|
|
): boolean {
|
|
const { preferredLink, links } = user.data;
|
|
if (!(website in links)) {
|
|
return false;
|
|
}
|
|
if (!preferredLink || preferredLink == website) {
|
|
return true;
|
|
}
|
|
return !preferredChoices.includes(preferredLink);
|
|
}
|
|
|
|
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite): string {
|
|
if (user.data.isAnonymous) {
|
|
return "anonymous";
|
|
}
|
|
switch (website) {
|
|
case "eka":
|
|
if ("eka" in user.data.links) {
|
|
return `:icon${getUsernameForWebsite(user, "eka")}:`;
|
|
}
|
|
break;
|
|
case "furaffinity":
|
|
if ("furaffinity" in user.data.links) {
|
|
return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
|
|
}
|
|
break;
|
|
case "weasyl":
|
|
const weasylPreferredWebsites = ["furaffinity", "inkbunny", "sofurry"] as const;
|
|
if ("weasyl" in user.data.links) {
|
|
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
|
|
} else if (isPreferredWebsite(user, "furaffinity", weasylPreferredWebsites)) {
|
|
return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`;
|
|
} else if (isPreferredWebsite(user, "inkbunny", weasylPreferredWebsites)) {
|
|
return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
|
|
} else if (isPreferredWebsite(user, "sofurry", weasylPreferredWebsites)) {
|
|
return `<sf:${getUsernameForWebsite(user, "sofurry")}>`;
|
|
}
|
|
break;
|
|
case "inkbunny":
|
|
const inkbunnyPreferredWebsites = ["furaffinity", "sofurry", "weasyl"] as const;
|
|
if ("inkbunny" in user.data.links) {
|
|
return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
|
|
} else if (isPreferredWebsite(user, "furaffinity", inkbunnyPreferredWebsites)) {
|
|
return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
|
|
} else if (isPreferredWebsite(user, "sofurry", inkbunnyPreferredWebsites)) {
|
|
return `[sf]${getUsernameForWebsite(user, "sofurry")}[/sf]`;
|
|
} else if (isPreferredWebsite(user, "weasyl", inkbunnyPreferredWebsites)) {
|
|
return `[weasyl]${getUsernameForWebsite(user, "weasyl")}[/weasyl]`;
|
|
}
|
|
break;
|
|
case "sofurry":
|
|
const sofurryPreferredWebsites = ["furaffinity", "inkbunny"] as const;
|
|
if ("sofurry" in user.data.links) {
|
|
return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
|
|
} else if (isPreferredWebsite(user, "furaffinity", sofurryPreferredWebsites)) {
|
|
return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
|
|
} else if (isPreferredWebsite(user, "inkbunny", sofurryPreferredWebsites)) {
|
|
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}" link for user "${user.id}" (consider setting preferredLink)`);
|
|
}
|
|
|
|
function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
|
|
if (user.data.isAnonymous) {
|
|
return anonymousUser.data.nameLang[lang] || anonymousUser.data.name;
|
|
}
|
|
return user.data.nameLang[lang] || user.data.name;
|
|
}
|
|
|
|
type Props = {
|
|
story: CollectionEntry<"stories">;
|
|
};
|
|
|
|
type Params = {
|
|
slug: CollectionEntry<"stories">["slug"];
|
|
};
|
|
|
|
export const getStaticPaths: GetStaticPaths = async () => {
|
|
if (import.meta.env.PROD) {
|
|
return [];
|
|
}
|
|
return (await getCollection("stories")).map((story) => ({
|
|
params: { slug: story.slug } satisfies Params,
|
|
props: { story } satisfies Props,
|
|
}));
|
|
};
|
|
|
|
export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
|
|
const { lang } = story.data;
|
|
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)[]
|
|
>,
|
|
);
|
|
|
|
const description: Record<ExportWebsite, string> = Object.fromEntries(
|
|
await Promise.all(
|
|
WEBSITE_LIST.map(async ([website, exportFormat]) => {
|
|
const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website);
|
|
const 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()})`,
|
|
);
|
|
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(`Unknown exportFormat "${exportFormat}"`);
|
|
}),
|
|
),
|
|
);
|
|
|
|
const anonymousUser = await getEntry("users", "anonymous");
|
|
const authorsNames = (await getEntries([story.data.authors].flat())).map((author) =>
|
|
getNameForUser(author, anonymousUser, lang),
|
|
);
|
|
const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
|
|
const requester = story.data.requester && (await getEntry(story.data.requester));
|
|
|
|
let storyHeader = `${story.data.title}\n`;
|
|
if (lang === "eng") {
|
|
let authorsString = `by ${authorsNames[0]}`;
|
|
if (authorsNames.length > 2) {
|
|
authorsString += `, ${authorsNames.slice(1, authorsNames.length - 1).join(", ")}, and ${authorsNames[authorsNames.length - 1]}`;
|
|
} else if (authorsNames.length == 2) {
|
|
authorsString += ` and ${authorsNames[1]}`;
|
|
}
|
|
storyHeader +=
|
|
`${authorsString}\n` +
|
|
(commissioner ? `Commissioned by ${getNameForUser(commissioner, anonymousUser, lang)}\n` : "") +
|
|
(requester ? `Requested by ${getNameForUser(requester, anonymousUser, lang)}\n` : "");
|
|
} else if (lang === "tok") {
|
|
let authorsString = "lipu ni li tan ";
|
|
if (authorsNames.length > 1) {
|
|
authorsString += `jan ni: ${authorsNames.join(" en ")}`;
|
|
} else {
|
|
authorsString += authorsNames[0];
|
|
}
|
|
if (commissioner) {
|
|
throw new Error(`No "commissioner" handler for language "tok"`);
|
|
}
|
|
if (requester) {
|
|
throw new Error(`No "requester" handler for language "tok"`);
|
|
}
|
|
storyHeader += `${authorsString}\n`;
|
|
} else {
|
|
throw new Error(`Unknown language "${lang}"`);
|
|
}
|
|
|
|
const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll("\\*", "*").replaceAll("\\=", "=")}`
|
|
.replaceAll(/\n\n\n+/g, "\n\n")
|
|
.trim();
|
|
|
|
const headers = { "Content-Type": "application/json; charset=utf-8" };
|
|
return new Response(
|
|
JSON.stringify({
|
|
story: storyText,
|
|
description,
|
|
thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
|
|
}),
|
|
{ headers },
|
|
);
|
|
};
|