From 37db38b613eeac1a7d839b999c4819a81b586be3 Mon Sep 17 00:00:00 2001 From: Bad Manners <me@badmanners.xyz> Date: Sat, 30 Mar 2024 15:04:29 -0300 Subject: [PATCH] Improve i18n support and config validation --- package-lock.json | 4 +- package.json | 2 +- src/components/UserComponent.astro | 12 +- src/content/config.ts | 63 +++++++--- src/i18n/index.ts | 60 ++++----- src/layouts/GameLayout.astro | 3 - src/layouts/StoryLayout.astro | 3 - src/pages/api/export-story/[...slug].ts | 154 ++++++++++-------------- src/pages/tags.astro | 3 - 9 files changed, 152 insertions(+), 152 deletions(-) diff --git a/package-lock.json b/package-lock.json index 78fd541..2d41de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gallery-badmanners-xyz", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gallery-badmanners-xyz", - "version": "1.2.0", + "version": "1.2.1", "dependencies": { "@astrojs/check": "^0.5.9", "@astrojs/rss": "^4.0.5", diff --git a/package.json b/package.json index 37626ab..26082b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gallery-badmanners-xyz", "type": "module", - "version": "1.2.0", + "version": "1.2.1", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro index b07eb85..9f6ab8e 100644 --- a/src/components/UserComponent.astro +++ b/src/components/UserComponent.astro @@ -15,15 +15,11 @@ if (user.data.isAnonymous) { const username = t(lang, user.data.nameLang as any) || user.data.name; let link: string | null = null; if (user.data.preferredLink) { - if (user.data.preferredLink in user.data.links) { - const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string]; - if (typeof preferredLink === "string") { - link = preferredLink; - } else { - link = preferredLink[0]; - } + const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string]; + if (typeof preferredLink === "string") { + link = preferredLink; } else { - throw new Error(`No preferredLink "${user.data.preferredLink}" for user ${user.id}`); + link = preferredLink[0]; } } --- diff --git a/src/content/config.ts b/src/content/config.ts index 4829f65..7f931ae 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -17,6 +17,8 @@ export const WEBSITE_LIST = [ ] as const; const localUrlRegex = /^((\/?\.\.(?=\/))+|(\.(?=\/)))?\/?[a-z0-9_-]+(\/[a-z0-9_-]+)*\/?$/; +const refineAuthors = (value: { id: any } | any[]) => "id" in value || value.length > 0; +const refineCopyrightedCharacters = (value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1; const lang = z.enum(["eng", "tok"]).default("eng"); const website = z.enum(WEBSITE_LIST); @@ -43,7 +45,10 @@ const storiesCollection = defineCollection({ // Optional isDraft: z.boolean().default(false), shortTitle: z.string().optional(), - authors: z.union([reference("users"), z.array(reference("users"))]).default("bad-manners"), + authors: z + .union([reference("users"), z.array(reference("users"))]) + .default("bad-manners") + .refine(refineAuthors, "authors cannot be empty"), descriptionPlaintext: z.string().optional(), summary: z.string().optional(), thumbnail: image().optional(), @@ -52,7 +57,13 @@ const storiesCollection = defineCollection({ series: reference("series").optional(), commissioner: reference("users").optional(), requester: reference("users").optional(), - copyrightedCharacters: z.record(z.string(), reference("users")).default({}), + copyrightedCharacters: z + .record(z.string(), reference("users")) + .default({}) + .refine( + refineCopyrightedCharacters, + "copyrightedCharacters cannot have an empty catch-all key with other keys", + ), lang, prev: reference("stories").nullish(), next: reference("stories").nullish(), @@ -74,13 +85,22 @@ const gamesCollection = defineCollection({ tags: z.array(z.string()), // Optional isDraft: z.boolean().default(false), - authors: z.union([reference("users"), z.array(reference("users"))]).default("bad-manners"), + authors: z + .union([reference("users"), z.array(reference("users"))]) + .default("bad-manners") + .refine(refineAuthors, "authors cannot be empty"), descriptionPlaintext: z.string().optional(), thumbnail: image().optional(), thumbnailWidth: z.number().int().optional(), thumbnailHeight: z.number().int().optional(), series: reference("series").optional(), - copyrightedCharacters: z.record(z.string(), reference("users")).default({}), + copyrightedCharacters: z + .record(z.string(), reference("users")) + .default({}) + .refine( + refineCopyrightedCharacters, + "copyrightedCharacters cannot have an empty catch-all key with other keys", + ), lang, relatedStories: z.array(reference("stories")).default([]), relatedGames: z.array(reference("games")).default([]), @@ -91,16 +111,24 @@ const gamesCollection = defineCollection({ const usersCollection = defineCollection({ type: "data", schema: ({ image }) => - z.object({ - // Required - name: z.string(), - links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])), - // Optional - preferredLink: website.nullish(), - nameLang: z.record(lang, z.string()).default({}), - avatar: image().optional(), - isAnonymous: z.boolean().default(false), - }), + z + .object({ + // Required + name: z.string(), + links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])), + // Optional + preferredLink: website.nullish(), + nameLang: z.record(lang, z.string()).default({}), + avatar: image().optional(), + isAnonymous: z.boolean().default(false), + }) + .refine( + ({ links, preferredLink }) => !preferredLink || preferredLink in links, + ({ preferredLink }) => ({ + message: `"${preferredLink}" not defined in links`, + path: ["preferredLink"], + }), + ), }); const seriesCollection = defineCollection({ @@ -118,7 +146,12 @@ const tagCategoriesCollection = defineCollection({ // Required name: z.string(), index: z.number().int(), - tags: z.array(z.union([z.string(), z.record(lang, z.string())])), + tags: z.array( + z.union([ + z.string(), + z.record(lang, z.string()).refine((tag) => "eng" in tag, 'Object-formatted tag must have an "eng" key'), + ]), + ), }), }); diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 2c64c6c..51eed70 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -7,6 +7,29 @@ export type TranslationRecord = { [DEFAULT_LANG]: string | ((...args: any[]) => }; export const UI_STRINGS: Record<string, TranslationRecord> = { + "util/join_names": { + eng: (names: string[]) => + names.length <= 1 + ? names.join("") + : names.length == 2 + ? `${names[0]} and ${names[1]}` + : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`, + tok: (names: string[]) => names.join(" en "), + }, + "export_story/warnings": { + eng: (wordCount: number | string, contentWarning: string) => `*Word count: ${wordCount}. ${contentWarning}*`, + tok: (_wordCount: number | string, contentWarning: string) => `*${contentWarning}*`, + }, + "export_story/writing": { + eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`, + tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`, + }, + "export_story/request_for": { + eng: (requester: string) => `Request for: ${requester}`, + }, + "export_story/commissioned_by": { + eng: (commissioner: string) => `Commissioned by: ${commissioner}`, + }, "story/return_to_stories": { eng: "Return to stories", tok: "o tawa e lipu ale", @@ -59,24 +82,12 @@ export const UI_STRINGS: Record<string, TranslationRecord> = { tok: "lipu lawa", }, "story/authors": { - eng: (authorsList: string[]) => { - let authorsString = `by ${authorsList[0]}`; - if (authorsList.length > 2) { - authorsString += `, ${authorsList.slice(1, authorsList.length - 1).join(", ")}, and ${authorsList[authorsList.length - 1]}`; - } else if (authorsList.length == 2) { - authorsString += ` and ${authorsList[1]}`; - } - return authorsString; - }, - tok: (authorsList: string[]) => { - let authorsString = "lipu ni li tan "; - if (authorsList.length > 1) { - authorsString += `jan ni: ${authorsList.join(" en ")}`; - } else { - authorsString += authorsList[0]; - } - return authorsString; - }, + eng: (authorsList: string[]) => + `by ${(UI_STRINGS["util/join_names"]!.eng as (arg: string[]) => string)(authorsList)}`, + tok: (authorsList: string[]) => + authorsList.length > 1 + ? `lipu ni li tan jan ni: ${(UI_STRINGS["util/join_names"]!.tok as (arg: string[]) => string)(authorsList)}` + : `lipu ni li tan ${authorsList[0]}`, }, "story/commissioned_by": { eng: (arg: string) => `Commissioned by ${arg}`, @@ -85,15 +96,10 @@ export const UI_STRINGS: Record<string, TranslationRecord> = { eng: (arg: string) => `Requested by ${arg}`, }, "characters/characters_are_copyrighted_by": { - eng: (owner: string, charactersList: string[]) => { - if (charactersList.length == 1) { - return `${charactersList[0]} is © ${owner}`; - } - if (charactersList.length == 2) { - return `${charactersList[0]} and ${charactersList[1]} are © ${owner}`; - } - return `${charactersList.slice(0, -1).join(", ")}, and ${charactersList[charactersList.length - 1]} are © ${owner}`; - }, + eng: (owner: string, charactersList: string[]) => + charactersList.length == 1 + ? `${charactersList[0]} is © ${owner}` + : `${(UI_STRINGS["util/join_names"]!["eng"] as (arg: string[]) => string)(charactersList)} are © ${owner}`, }, "characters/all_characters_are_copyrighted_by": { eng: (owner: string) => `All characters are © ${owner}`, diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro index 5f17b12..cd178a4 100644 --- a/src/layouts/GameLayout.astro +++ b/src/layouts/GameLayout.astro @@ -16,9 +16,6 @@ type Props = CollectionEntry<"games">["data"]; const { props } = Astro; const series = props.series && (await getEntry(props.series)); const authors = await getEntries([props.authors].flat()); -if ("" in props.copyrightedCharacters && Object.keys(props.copyrightedCharacters).length > 1) { - throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys"); -} const copyrightedCharacters = await Promise.all( Object.values( Object.keys(props.copyrightedCharacters).reduce( diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro index 60ba7cc..3dd5829 100644 --- a/src/layouts/StoryLayout.astro +++ b/src/layouts/StoryLayout.astro @@ -26,9 +26,6 @@ const series = props.series && (await getEntry(props.series)); const authors = await getEntries([props.authors].flat()); const commissioner = props.commissioner && (await getEntry(props.commissioner)); const requester = props.requester && (await getEntry(props.requester)); -if ("" in props.copyrightedCharacters && Object.keys(props.copyrightedCharacters).length > 1) { - throw new Error("copyrightedCharacter cannot use empty key (catch-all) with other keys"); -} const copyrightedCharacters = await Promise.all( Object.values( Object.keys(props.copyrightedCharacters).reduce( diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts index f769719..e80e460 100644 --- a/src/pages/api/export-story/[...slug].ts +++ b/src/pages/api/export-story/[...slug].ts @@ -5,7 +5,7 @@ import { decode as tinyDecode } from "tiny-decode"; import { type Lang, type Website } from "../../../content/config"; import { t } from "../../../i18n"; -type DescriptionFormat = "bbcode" | "markdown"; +type ExportFormat = "bbcode" | "markdown"; const WEBSITE_LIST = [ ["eka", "bbcode"], @@ -13,9 +13,9 @@ const WEBSITE_LIST = [ ["inkbunny", "bbcode"], ["sofurry", "bbcode"], ["weasyl", "markdown"], -] as const satisfies [Website, DescriptionFormat][]; +] as const satisfies [Website, ExportFormat][]; -type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionFormat]> ? K : never; +type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, ExportFormat]> ? K : never; const bbcodeRenderer: RendererApi = { strong: (text) => `[b]${text}[/b]`, @@ -108,7 +108,7 @@ function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website) } break; default: - throw new Error(`Unhandled website "${website}" in getUsernameForWebsite`); + throw new Error(`Unhandled Website "${website}"`); } } else { return link[1].replace(/^@/, ""); @@ -117,24 +117,14 @@ function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website) 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 isPreferredWebsite(user: CollectionEntry<"users">, website: Website): boolean { + const { preferredLink } = user.data; + return !preferredLink || preferredLink == website; } -function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite): string { +function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite, anonymousFallback: string): string { if (user.data.isAnonymous) { - return "anonymous"; + return anonymousFallback; } switch (website) { case "eka": @@ -148,51 +138,46 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite): } 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)) { + } else if (isPreferredWebsite(user, "furaffinity")) { return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`; - } else if (isPreferredWebsite(user, "inkbunny", weasylPreferredWebsites)) { + } else if (isPreferredWebsite(user, "inkbunny")) { return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`; - } else if (isPreferredWebsite(user, "sofurry", weasylPreferredWebsites)) { + } else if (isPreferredWebsite(user, "sofurry")) { 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)) { + } else if (isPreferredWebsite(user, "furaffinity")) { return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`; - } else if (isPreferredWebsite(user, "sofurry", inkbunnyPreferredWebsites)) { + } else if (isPreferredWebsite(user, "sofurry")) { return `[sf]${getUsernameForWebsite(user, "sofurry")}[/sf]`; - } else if (isPreferredWebsite(user, "weasyl", inkbunnyPreferredWebsites)) { + } else if (isPreferredWebsite(user, "weasyl")) { 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)) { + } else if (isPreferredWebsite(user, "furaffinity")) { return `fa!${getUsernameForWebsite(user, "furaffinity")}`; - } else if (isPreferredWebsite(user, "inkbunny", sofurryPreferredWebsites)) { + } else if (isPreferredWebsite(user, "inkbunny")) { return `ib!${getUsernameForWebsite(user, "inkbunny")}`; } break; default: - throw new Error(`Unhandled website "${website}" in getLinkForUser`); + throw new Error(`Unhandled ExportWebsite "${website}"`); } 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}`); - } + const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string]; + return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`; } - throw new Error(`No "${website}" link for user "${user.id}" (consider setting preferredLink)`); + throw new Error( + `No matching "${website}" link for user "${user.id}" (consider setting their "preferredLink" property)`, + ); } function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string { @@ -222,55 +207,47 @@ export const getStaticPaths: GetStaticPaths = async () => { 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 copyrightedCharacters = await Promise.all( + Object.values( + Object.keys(story.data.copyrightedCharacters).reduce( + (acc, character) => { + const user = story.data.copyrightedCharacters[character]; + if (!(user.id in acc)) { + acc[user.id] = [getEntry(user), []]; + } + acc[user.id][1].push(character); + return acc; + }, + {} as Record<string, [Promise<CollectionEntry<"users">>, string[]]>, + ), + ).map(async ([userPromise, characters]) => [await userPromise, characters] as [CollectionEntry<"users">, string[]]), + ); + const authorsList = await getEntries([story.data.authors].flat()); + const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner)); + const requester = story.data.requester && (await getEntry(story.data.requester)); + const anonymousUser = await getEntry("users", "anonymous"); + const anonymousFallback = getNameForUser(anonymousUser, anonymousUser, lang); 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 u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback); 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}`; - }), - )), + t(lang, "export_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") @@ -290,25 +267,22 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) = if (exportFormat === "markdown") { return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()]; } - throw new Error(`Unknown exportFormat "${exportFormat}"`); + throw new Error(`Unhandled 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)); - const storyHeader = `${story.data.title}\n` + - `${t(lang, "story/authors", authorsNames)}\n` + + `${t( + lang, + "story/authors", + authorsList.map((author) => getNameForUser(author, anonymousUser, lang)), + )}\n` + (commissioner ? `${t(lang, "story/commissioned_by", getNameForUser(commissioner, anonymousUser, lang))}\n` : "") + (requester ? `${t(lang, "story/requested_by", getNameForUser(requester, anonymousUser, lang))}\n` : ""); - const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll("\\*", "*").replaceAll("\\=", "=")}` + const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}` .replaceAll(/\n\n\n+/g, "\n\n") .trim(); diff --git a/src/pages/tags.astro b/src/pages/tags.astro index 0c10217..ebd1ca6 100644 --- a/src/pages/tags.astro +++ b/src/pages/tags.astro @@ -52,9 +52,6 @@ const categorizedTags: Array<[string, string, string[]]> = tagCategories if (typeof tag === "string") { return tag; } - if (!("eng" in tag)) { - throw new Error(`Object-formatted tag must have an "eng" key: ${tag}`); - } return tag["eng"]!; }); tagList.forEach((tag, index) => {