import type { APIRoute, GetStaticPaths } from "astro"; import { getCollection, type CollectionEntry, getEntries } from "astro:content"; import type { PostWebsite } from "src/content.config"; import { t } from "@i18n"; import { formatCopyrightedCharacters } from "@utils/format_copyrighted_characters"; import { markdownToBbcode } from "@utils/markdown_to_bbcode"; import { getUsernameForLang } from "@utils/get_username_for_lang"; import { isAnonymousUser } from "@utils/is_anonymous_user"; import { qualifyLocalURLsInMarkdown } from "@utils/qualify_local_urls_in_markdown"; import { getWebsiteLinkForUser } from "@utils/get_website_link_for_user"; import { toPlainMarkdown } from "@utils/to_plain_markdown"; interface ExportWebsiteInfo { website: PostWebsite; exportFormat: "bbcode" | "markdown"; } const WEBSITE_LIST = [ { website: "eka", exportFormat: "bbcode" }, { website: "furaffinity", exportFormat: "bbcode" }, { website: "inkbunny", exportFormat: "bbcode" }, { website: "sofurry", exportFormat: "bbcode" }, { website: "weasyl", exportFormat: "markdown" }, ] as const satisfies ExportWebsiteInfo[]; type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never; export type ExportStoryResponse = { story: string; description: Record; thumbnail: string | null; }; type Props = { story: CollectionEntry<"stories">; }; type Params = { id: CollectionEntry<"stories">["id"]; }; export const getStaticPaths: GetStaticPaths = async () => { if (import.meta.env.PROD) { return []; } return (await getCollection("stories")).map((story) => ({ params: { id: story.id } satisfies Params, props: { story } satisfies Props, })); }; export const GET: APIRoute = async ({ props: { story }, site }) => { try { const { lang } = story.data; if (!story.body) { throw new Error("Story body cannot be empty"); } const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters); const authorsList = await getEntries(story.data.authors); const commissionersList = story.data.commissioners && (await getEntries(story.data.commissioners)); const requestersList = story.data.requesters && (await getEntries(story.data.requesters)); const description = await Promise.all( WEBSITE_LIST.map(async ({ website, exportFormat }) => { const exportWebsite: ExportWebsiteName = website; const u = (user: CollectionEntry<"users">) => isAnonymousUser(user) ? getUsernameForLang(user, lang) : getWebsiteLinkForUser(user, exportWebsite, (user) => getUsernameForLang(user, lang)); const storyDescription = await [ story.data.description, `*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`, t( lang, "export_story/authors", authorsList.map((author) => u(author)), ), requestersList && t( lang, "export_story/request_for", requestersList.map((requester) => u(requester)), ), commissionersList && t( lang, "export_story/commissioned_by", commissionersList.map((commissioner) => u(commissioner)), ), ...copyrightedCharacters.map(({ user, characters }) => t(lang, "characters/characters_are_copyrighted_by", u(user), characters[0] === "" ? [] : characters), ), ].reduce(async (promise, data) => { if (!data) { return promise; } const acc = await promise; const newData = await qualifyLocalURLsInMarkdown(data, lang, site, exportWebsite); return `${acc}\n\n${newData}`; }, Promise.resolve("")); switch (exportFormat) { case "bbcode": return { descriptionFilename: `description_${exportWebsite}.txt`, descriptionText: markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n"), }; case "markdown": return { descriptionFilename: `description_${exportWebsite}.md`, descriptionText: toPlainMarkdown(storyDescription) .replaceAll(/\n\n\n+/g, "\n\n") .trim(), }; default: const unknown: never = exportFormat; throw new Error(`Unknown export format "${unknown}"`); } }), ); const storyHeader = `${story.data.title}\n` + `${t( lang, "story/authors", authorsList.map((author) => getUsernameForLang(author, lang)), )}\n` + (requestersList ? `${t( lang, "story/requested_by", requestersList.map((requester) => getUsernameForLang(requester, lang)), )}\n` : "") + (commissionersList ? `${t( lang, "story/commissioned_by", commissionersList.map((commissioner) => getUsernameForLang(commissioner, lang)), )}\n` : ""); const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}` .replaceAll(/\n\n\n+/g, "\n\n") .trim(); return new Response( JSON.stringify({ story: storyText, description: description.reduce( (acc, item) => { acc[item.descriptionFilename] = item.descriptionText; return acc; }, {} as Record, ), thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null, } satisfies ExportStoryResponse), { headers: { "Content-Type": "application/json; charset=utf-8" } }, ); } catch (e) { return new Response( JSON.stringify({ message: (e as Error).message ?? null, stack: (e as Error).stack ?? null, }), { status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }, ); } };