Improve i18n support and config validation

This commit is contained in:
Bad Manners 2024-03-30 15:04:29 -03:00
parent 4f83ae8802
commit 37db38b613
9 changed files with 152 additions and 152 deletions

4
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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];
}
}
---

View file

@ -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'),
]),
),
}),
});

View file

@ -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}`,

View file

@ -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(

View file

@ -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(

View file

@ -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();

View file

@ -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) => {