From 17ef8c652c0737a40dfbb010e88caeee58ac7b50 Mon Sep 17 00:00:00 2001 From: Bad Manners <me@badmanners.xyz> Date: Fri, 26 Jul 2024 15:48:57 -0300 Subject: [PATCH] Improve schema and update tags - Make constants in schema explicit - Enumerate tag-categories - More i18n utilities - Better anonymous user support without special field - Remove most tuples and unchecked type-casting --- package-lock.json | 4 +- package.json | 2 +- src/components/UserComponent.astro | 13 +-- src/content/config.ts | 97 ++++++++++--------- src/content/games/crossing-over.md | 2 +- src/content/stories/accommodation.md | 2 +- src/content/stories/addictive-additions.md | 2 +- src/content/stories/better-in-bully-batter.md | 2 +- src/content/stories/latest-catch.md | 3 +- src/content/stories/overzealous-zenko.md | 2 +- src/content/stories/team-effort.md | 1 + ...ypes-of-vore.yaml => 1-types-of-vore.yaml} | 2 +- ...ters.yaml => 10-recurring-characters.yaml} | 2 +- .../{body-types.yaml => 2-body-types.yaml} | 2 +- .../{genders.yaml => 3-genders.yaml} | 2 +- ...elative-size.yaml => 4-relative-size.yaml} | 2 +- .../{willingness.yaml => 5-willingness.yaml} | 2 +- ...ios.yaml => 6-vore-related-scenarios.yaml} | 2 +- ...ual-content.yaml => 7-sexual-content.yaml} | 2 +- .../{other-kinks.yaml => 8-other-kinks.yaml} | 2 +- ...of-content.yaml => 9-type-of-content.yaml} | 2 +- src/content/users/anonymous.yaml | 3 +- src/content/users/bad-manners.yaml | 2 +- src/i18n/index.ts | 76 +++++++++------ src/layouts/GameLayout.astro | 16 +-- src/layouts/StoryLayout.astro | 16 +-- src/pages/api/export-story/[...slug].ts | 29 ++---- src/pages/feed.xml.ts | 2 +- src/pages/index.astro | 2 +- src/pages/stories/[page].astro | 7 +- src/pages/tags.astro | 44 ++++++--- src/pages/tags/[slug].astro | 80 ++++++--------- src/utils/get_username_for_lang.ts | 11 ++- src/utils/is_anonymous_user.ts | 6 ++ 34 files changed, 223 insertions(+), 221 deletions(-) rename src/content/tag-categories/{types-of-vore.yaml => 1-types-of-vore.yaml} (99%) rename src/content/tag-categories/{recurring-characters.yaml => 10-recurring-characters.yaml} (97%) rename src/content/tag-categories/{body-types.yaml => 2-body-types.yaml} (99%) rename src/content/tag-categories/{genders.yaml => 3-genders.yaml} (99%) rename src/content/tag-categories/{relative-size.yaml => 4-relative-size.yaml} (99%) rename src/content/tag-categories/{willingness.yaml => 5-willingness.yaml} (99%) rename src/content/tag-categories/{vore-related-scenarios.yaml => 6-vore-related-scenarios.yaml} (99%) rename src/content/tag-categories/{sexual-content.yaml => 7-sexual-content.yaml} (99%) rename src/content/tag-categories/{other-kinks.yaml => 8-other-kinks.yaml} (99%) rename src/content/tag-categories/{type-of-content.yaml => 9-type-of-content.yaml} (97%) create mode 100644 src/utils/is_anonymous_user.ts diff --git a/package-lock.json b/package-lock.json index 1be4ca0..7fc89b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gallery-badmanners-xyz", - "version": "1.5.4", + "version": "1.5.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gallery-badmanners-xyz", - "version": "1.5.4", + "version": "1.5.5", "dependencies": { "@astrojs/check": "^0.8.2", "@astrojs/rss": "^4.0.7", diff --git a/package.json b/package.json index 6084abb..28e83e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gallery-badmanners-xyz", "type": "module", - "version": "1.5.4", + "version": "1.5.5", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro index e8cd6d3..18ba37e 100644 --- a/src/components/UserComponent.astro +++ b/src/components/UserComponent.astro @@ -1,5 +1,5 @@ --- -import { type CollectionEntry, getEntry } from "astro:content"; +import { type CollectionEntry } from "astro:content"; import { type Lang } from "../content/config"; import { getUsernameForLang } from "../utils/get_username_for_lang"; @@ -9,13 +9,10 @@ type Props = { }; let { user, lang } = Astro.props; -if (user.data.isAnonymous) { - user = await getEntry("users", "anonymous"); -} const username = getUsernameForLang(user, lang); let link: string | null = null; if (user.data.preferredLink) { - const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string]; + const preferredLink = user.data.links[user.data.preferredLink]!; if (typeof preferredLink === "string") { link = preferredLink; } else { @@ -25,11 +22,11 @@ if (user.data.preferredLink) { --- { - user.data.isAnonymous || !user.data.preferredLink ? ( - <span>{username}</span> - ) : ( + link ? ( <a href={link} class="text-link underline" target="_blank"> {username} </a> + ) : ( + <span>{username}</span> ) } diff --git a/src/content/config.ts b/src/content/config.ts index 7993dfe..2956919 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -16,6 +16,8 @@ export const WEBSITE_LIST = [ ] as const; export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const; export const DEFAULT_LANG = "eng"; +export const DEFAULT_AUTHOR = "bad-manners"; +export const ANONYMOUS_USER = "anonymous"; // Validators @@ -27,13 +29,34 @@ const inkbunnyPostUrlRegex = /^(?:https:\/\/)(?:www\.)?inkbunny\.net\/s\/([1-9]\ const sofurryPostUrlRegex = /^(?:https:\/\/)www\.sofurry\.com\/view\/([1-9]\d*)\/?$/; const mastodonPostUrlRegex = /^(?:https:\/\/)((?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@([a-zA-Z][a-zA-Z0-9_-]+)\/([1-9]\d*)\/?$/; -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 refineAuthors = [ + (value: { id: any } | any[]) => "id" in value || value.length > 0, + `"authors" cannot be empty`, +] as const; +const refineCopyrightedCharacters = [ + (value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1, + `"copyrightedCharacters" cannot mix empty catch-all key with other keys`, +] as const; // Transformers export const adjustDateForUTCOffset = (date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0); +export const parseMastodonPostUrl = (url: string, ctx: z.RefinementCtx) => { + const match = mastodonPostUrlRegex.exec(url); + if (!match) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"mastodon" post contains an invalid URL`, + }); + return z.NEVER; + } + return { + instance: match[1]!, + user: match[2]!, + postId: match[3]!, + }; +}; // Types @@ -46,27 +69,15 @@ const mastodonPost = z user: z.string(), postId: z.string(), }) - .or( - z.string().transform((mastodonPost, ctx) => { - const match = mastodonPostUrlRegex.exec(mastodonPost); - if (!match) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `"mastodon" post contains an invalid URL`, - }); - return z.NEVER; - } - return { - instance: match[1], - user: match[2], - postId: match[3], - }; - }), - ); + .or(z.string().transform(parseMastodonPostUrl)); +const authors = z + .union([reference("users"), z.array(reference("users"))]) + .default(DEFAULT_AUTHOR) + .refine(...refineAuthors); const copyrightedCharacters = z .record(z.string(), reference("users")) .default({}) - .refine(refineCopyrightedCharacters, `"copyrightedCharacters" cannot mix empty catch-all key with other keys`); + .refine(...refineCopyrightedCharacters); export type Lang = z.output<typeof lang>; export type Website = z.infer<typeof website>; @@ -89,10 +100,7 @@ const storiesCollection = defineCollection({ pubDate: z.date().transform(adjustDateForUTCOffset).optional(), isDraft: z.boolean().default(false), shortTitle: z.string().optional(), - authors: z - .union([reference("users"), z.array(reference("users"))]) - .default("bad-manners") - .refine(refineAuthors, `"authors" cannot be empty`), + authors, summary: z.string().optional(), thumbnail: image().optional(), thumbnailWidth: z.number().int().optional(), @@ -108,13 +116,14 @@ const storiesCollection = defineCollection({ relatedGames: z.array(reference("games")).default([]), posts: z .object({ - eka: z.string().regex(ekaPostUrlRegex).optional(), - furaffinity: z.string().regex(furaffinityPostUrlRegex).optional(), - weasyl: z.string().regex(weasylPostUrlRegex).optional(), - inkbunny: z.string().regex(inkbunnyPostUrlRegex).optional(), - sofurry: z.string().regex(sofurryPostUrlRegex).optional(), - mastodon: mastodonPost.optional(), + eka: z.string().regex(ekaPostUrlRegex), + furaffinity: z.string().regex(furaffinityPostUrlRegex), + weasyl: z.string().regex(weasylPostUrlRegex), + inkbunny: z.string().regex(inkbunnyPostUrlRegex), + sofurry: z.string().regex(sofurryPostUrlRegex), + mastodon: mastodonPost, }) + .partial() .default({}), }), }); @@ -131,10 +140,7 @@ const gamesCollection = defineCollection({ // Optional pubDate: z.date().transform(adjustDateForUTCOffset).optional(), isDraft: z.boolean().default(false), - authors: z - .union([reference("users"), z.array(reference("users"))]) - .default("bad-manners") - .refine(refineAuthors, `"authors" cannot be empty`), + authors, thumbnail: image().optional(), thumbnailWidth: z.number().int().optional(), thumbnailHeight: z.number().int().optional(), @@ -146,13 +152,14 @@ const gamesCollection = defineCollection({ relatedGames: z.array(reference("games")).default([]), posts: z .object({ - eka: z.string().regex(ekaPostUrlRegex).optional(), - furaffinity: z.string().regex(furaffinityPostUrlRegex).optional(), - weasyl: z.string().regex(weasylPostUrlRegex).optional(), - inkbunny: z.string().regex(inkbunnyPostUrlRegex).optional(), - sofurry: z.string().regex(sofurryPostUrlRegex).optional(), - mastodon: mastodonPost.optional(), + eka: z.string().regex(ekaPostUrlRegex), + furaffinity: z.string().regex(furaffinityPostUrlRegex), + weasyl: z.string().regex(weasylPostUrlRegex), + inkbunny: z.string().regex(inkbunnyPostUrlRegex), + sofurry: z.string().regex(sofurryPostUrlRegex), + mastodon: mastodonPost, }) + .partial() .default({}), }), }); @@ -169,9 +176,8 @@ const usersCollection = defineCollection({ 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({}), + lang: z.record(lang, z.string()).default({}), avatar: image().optional(), - isAnonymous: z.boolean().default(false), }) .refine( ({ links, preferredLink }) => !preferredLink || preferredLink in links, @@ -187,7 +193,7 @@ const seriesCollection = defineCollection({ schema: z.object({ // Required name: z.string(), - url: z.string().regex(/^(\/[a-z0-9_-]+)*\/?$/, `"url" must be a local URL`), + url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`), }), }); @@ -199,10 +205,7 @@ const tagCategoriesCollection = defineCollection({ index: z.number().int(), tags: z.array( z.object({ - name: z.union([ - z.string(), - z.intersection(z.object({ [DEFAULT_LANG]: z.string() }), z.record(lang, z.string())), - ]), + name: z.union([z.string(), z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()))]), description: z.string().optional(), related: z.array(z.string()).optional(), }), diff --git a/src/content/games/crossing-over.md b/src/content/games/crossing-over.md index ac9f427..8135d42 100644 --- a/src/content/games/crossing-over.md +++ b/src/content/games/crossing-over.md @@ -38,7 +38,7 @@ tags: - non-binary prey - micro prey - soul vore - - implied perma endo + - long-term endo --- <iframe diff --git a/src/content/stories/accommodation.md b/src/content/stories/accommodation.md index 40dd5e3..0a1efdf 100644 --- a/src/content/stories/accommodation.md +++ b/src/content/stories/accommodation.md @@ -27,7 +27,7 @@ tags: - unwilling prey - willing prey - size difference - - long-term endo + - implied perma endo - straight sex --- diff --git a/src/content/stories/addictive-additions.md b/src/content/stories/addictive-additions.md index b1a24f8..2a4d7cd 100644 --- a/src/content/stories/addictive-additions.md +++ b/src/content/stories/addictive-additions.md @@ -29,7 +29,7 @@ tags: - willing prey - semi-willing prey - similar size - - perma endo + - implied perma endo - straight sex - gay sex - hyper diff --git a/src/content/stories/better-in-bully-batter.md b/src/content/stories/better-in-bully-batter.md index 55e0495..d48fe06 100644 --- a/src/content/stories/better-in-bully-batter.md +++ b/src/content/stories/better-in-bully-batter.md @@ -26,7 +26,7 @@ tags: - willing predator - unwilling prey - similar size - - perma endo + - implied perma endo - straight sex - gay sex - orgy diff --git a/src/content/stories/latest-catch.md b/src/content/stories/latest-catch.md index 5783e4e..7e7df5e 100644 --- a/src/content/stories/latest-catch.md +++ b/src/content/stories/latest-catch.md @@ -26,7 +26,8 @@ tags: - willing prey - size difference - masturbation - - perma endo + - long-term endo + - implied perma endo - flash fiction --- diff --git a/src/content/stories/overzealous-zenko.md b/src/content/stories/overzealous-zenko.md index 360ae8a..9123068 100644 --- a/src/content/stories/overzealous-zenko.md +++ b/src/content/stories/overzealous-zenko.md @@ -26,7 +26,7 @@ tags: - willing predator - unwilling prey - size difference - - perma endo + - implied perma endo - request requester: dee-lumeni copyrightedCharacters: diff --git a/src/content/stories/team-effort.md b/src/content/stories/team-effort.md index 57d12fa..3320ea5 100644 --- a/src/content/stories/team-effort.md +++ b/src/content/stories/team-effort.md @@ -26,6 +26,7 @@ tags: - semi-willing predator - willing predator - willing prey + - long-term endo - same size - hyper - inflation diff --git a/src/content/tag-categories/types-of-vore.yaml b/src/content/tag-categories/1-types-of-vore.yaml similarity index 99% rename from src/content/tag-categories/types-of-vore.yaml rename to src/content/tag-categories/1-types-of-vore.yaml index 1c9df3e..1d01e6f 100644 --- a/src/content/tag-categories/types-of-vore.yaml +++ b/src/content/tag-categories/1-types-of-vore.yaml @@ -1,5 +1,5 @@ name: Types of vore -index: 0 +index: 1 tags: - name: { eng: oral vore, tok: moku musi kepeken uta } description: Scenarios where prey are consumed by the predator through their mouth. diff --git a/src/content/tag-categories/recurring-characters.yaml b/src/content/tag-categories/10-recurring-characters.yaml similarity index 97% rename from src/content/tag-categories/recurring-characters.yaml rename to src/content/tag-categories/10-recurring-characters.yaml index 04771b0..ad2f9dc 100644 --- a/src/content/tag-categories/recurring-characters.yaml +++ b/src/content/tag-categories/10-recurring-characters.yaml @@ -1,5 +1,5 @@ name: Recurring characters -index: 9 +index: 10 tags: - name: Sam Brendan description: Content that includes my character and fursona [Sam, the mimic x maned wolf hybrid](https://badmanners.xyz/sam_brendan). diff --git a/src/content/tag-categories/body-types.yaml b/src/content/tag-categories/2-body-types.yaml similarity index 99% rename from src/content/tag-categories/body-types.yaml rename to src/content/tag-categories/2-body-types.yaml index 8c19cce..829e7b8 100644 --- a/src/content/tag-categories/body-types.yaml +++ b/src/content/tag-categories/2-body-types.yaml @@ -1,5 +1,5 @@ name: Body types -index: 1 +index: 2 tags: - name: anthro predator description: Scenarios where at least one of the predators is an anthropomorphic animal, i.e. generally regarded as a "furry". diff --git a/src/content/tag-categories/genders.yaml b/src/content/tag-categories/3-genders.yaml similarity index 99% rename from src/content/tag-categories/genders.yaml rename to src/content/tag-categories/3-genders.yaml index fd14dab..ffdaa6a 100644 --- a/src/content/tag-categories/genders.yaml +++ b/src/content/tag-categories/3-genders.yaml @@ -1,5 +1,5 @@ name: Genders -index: 2 +index: 3 tags: - name: male predator description: Scenarios where at least one of the predators is a man and/or male-presenting. diff --git a/src/content/tag-categories/relative-size.yaml b/src/content/tag-categories/4-relative-size.yaml similarity index 99% rename from src/content/tag-categories/relative-size.yaml rename to src/content/tag-categories/4-relative-size.yaml index 5ddbcb3..a123ff5 100644 --- a/src/content/tag-categories/relative-size.yaml +++ b/src/content/tag-categories/4-relative-size.yaml @@ -1,5 +1,5 @@ name: Relative size -index: 3 +index: 4 tags: - name: macro predator description: Scenarios where at least one of the predators has a size/height one or more orders of magnitude larger than average. diff --git a/src/content/tag-categories/willingness.yaml b/src/content/tag-categories/5-willingness.yaml similarity index 99% rename from src/content/tag-categories/willingness.yaml rename to src/content/tag-categories/5-willingness.yaml index 1b1edd6..c3855cb 100644 --- a/src/content/tag-categories/willingness.yaml +++ b/src/content/tag-categories/5-willingness.yaml @@ -1,5 +1,5 @@ name: Willingness -index: 4 +index: 5 tags: - name: { eng: willing predator, tok: jan pi wawa mute li wile e moku musi } description: Scenarios where at least one of the predators participates in vore willingly. diff --git a/src/content/tag-categories/vore-related-scenarios.yaml b/src/content/tag-categories/6-vore-related-scenarios.yaml similarity index 99% rename from src/content/tag-categories/vore-related-scenarios.yaml rename to src/content/tag-categories/6-vore-related-scenarios.yaml index f6f6d61..83fb042 100644 --- a/src/content/tag-categories/vore-related-scenarios.yaml +++ b/src/content/tag-categories/6-vore-related-scenarios.yaml @@ -1,5 +1,5 @@ name: Vore-related scenarios -index: 5 +index: 6 tags: - name: point of view description: Scenarios where the narration takes the perspective of one of the characters, generally in first person. diff --git a/src/content/tag-categories/sexual-content.yaml b/src/content/tag-categories/7-sexual-content.yaml similarity index 99% rename from src/content/tag-categories/sexual-content.yaml rename to src/content/tag-categories/7-sexual-content.yaml index 219cd3f..8e7dba2 100644 --- a/src/content/tag-categories/sexual-content.yaml +++ b/src/content/tag-categories/7-sexual-content.yaml @@ -1,5 +1,5 @@ name: Sexual content -index: 6 +index: 7 tags: - name: nudity description: Scenarios where a character is nude and displaying their sexual features, without necessarily participating in a sexual situation. diff --git a/src/content/tag-categories/other-kinks.yaml b/src/content/tag-categories/8-other-kinks.yaml similarity index 99% rename from src/content/tag-categories/other-kinks.yaml rename to src/content/tag-categories/8-other-kinks.yaml index 10cf7ef..ad1f679 100644 --- a/src/content/tag-categories/other-kinks.yaml +++ b/src/content/tag-categories/8-other-kinks.yaml @@ -1,5 +1,5 @@ name: Other kinks -index: 7 +index: 8 tags: - name: hyper description: Scenarios where a character has abnormally large features compared to their body, usually genitalia. diff --git a/src/content/tag-categories/type-of-content.yaml b/src/content/tag-categories/9-type-of-content.yaml similarity index 97% rename from src/content/tag-categories/type-of-content.yaml rename to src/content/tag-categories/9-type-of-content.yaml index f4e1faa..b1a76f6 100644 --- a/src/content/tag-categories/type-of-content.yaml +++ b/src/content/tag-categories/9-type-of-content.yaml @@ -1,5 +1,5 @@ name: Type of content -index: 8 +index: 9 tags: - name: request description: Stories made by someone else's request, as a gift. diff --git a/src/content/users/anonymous.yaml b/src/content/users/anonymous.yaml index 1c00098..725fb73 100644 --- a/src/content/users/anonymous.yaml +++ b/src/content/users/anonymous.yaml @@ -1,6 +1,5 @@ name: Anonymous -nameLang: +lang: eng: anonymous tok: jan pi nimi ala -isAnonymous: true links: {} diff --git a/src/content/users/bad-manners.yaml b/src/content/users/bad-manners.yaml index 6fcdbdf..015a17e 100644 --- a/src/content/users/bad-manners.yaml +++ b/src/content/users/bad-manners.yaml @@ -1,5 +1,5 @@ name: Bad Manners -nameLang: +lang: eng: Bad Manners tok: nasin ike Pemene avatar: /src/assets/images/logo_bm.png diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 4826028..78ea03d 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -2,7 +2,7 @@ import { type GamePlatform, type Lang } from "../content/config"; import { DEFAULT_LANG } from "../content/config"; export { DEFAULT_LANG } from "../content/config"; -export const UI_STRINGS = { +const UI_STRINGS = { "util/join_names": { eng: (names: string[]) => names.length <= 1 @@ -12,25 +12,24 @@ export const UI_STRINGS = { : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`, tok: (names: string[]) => names.join(" en "), }, + "util/capitalize": { + eng: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""), + }, + "util/enumerate": { + eng: (count: number, nounSingular: string, nounPlural: string | undefined) => + count !== 1 ? `${count !== 0 ? count : "zero"} ${nounPlural ?? nounSingular}` : `one ${nounSingular}`, + tok: (count: number, nounSingular: string, nounPlural: string | undefined) => + `${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`, + }, "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: (requesterList: string | string[]) => { - if (typeof requesterList === "string") { - requesterList = [requesterList]; - } - return `Request for: ${requesterList.join(" ")}`; - }, + eng: (requesterList: string | string[]) => `Request for: ${[requesterList].flat().join(" ")}`, }, "export_story/commissioned_by": { - eng: (commissionerList: string | string[]) => { - if (typeof commissionerList === "string") { - commissionerList = [commissionerList]; - } - return `Commissioned by: ${commissionerList.join(" ")}`; - }, + eng: (commissionerList: string | string[]) => `Commissioned by: ${[commissionerList].flat().join(" ")}`, }, "story/return_to_stories": { eng: "Return to stories", @@ -48,8 +47,9 @@ export const UI_STRINGS = { tok: "o ante e kule lipu", }, "story/warnings": { - eng: (wordCount: number | string, contentWarning: string) => `Word count: ${wordCount}. ${contentWarning}`, - tok: (_wordCount: number | string, contentWarning: string) => `${contentWarning}`, + eng: (wordCount: number | string | undefined, contentWarning: string) => + wordCount ? `Word count: ${wordCount}. ${contentWarning}` : contentWarning, + tok: (_wordCount: number | string | undefined, contentWarning: string) => contentWarning, }, "story/publish_date": { eng: (date: string) => date, @@ -91,20 +91,12 @@ export const UI_STRINGS = { : `lipu ni li tan ${authorsList[0]}`, }, "story/commissioned_by": { - eng: (commissionersList: string | string[]) => { - if (typeof commissionersList === "string") { - commissionersList = [commissionersList]; - } - return `Commissioned by ${UI_STRINGS["util/join_names"].eng(commissionersList)}`; - }, + eng: (commissionersList: string | string[]) => + `Commissioned by ${UI_STRINGS["util/join_names"].eng([commissionersList].flat())}`, }, "story/requested_by": { - eng: (requestersList: string | string[]) => { - if (typeof requestersList === "string") { - requestersList = [requestersList]; - } - return `Requested by ${UI_STRINGS["util/join_names"].eng(requestersList)}`; - }, + eng: (requestersList: string | string[]) => + `Requested by ${UI_STRINGS["util/join_names"].eng([requestersList].flat())}`, }, "story/draft_warning": { eng: "DRAFT VERSION – DO NOT REDISTRIBUTE", @@ -120,9 +112,16 @@ export const UI_STRINGS = { }, "game/platforms": { eng: (platforms: GamePlatform[]) => { - const translatedPlatforms = platforms.map( - (platform) => (UI_STRINGS[`game/platform_${platform}`].eng as string | undefined) || platform, - ); + if (platforms.length == 0) { + return ""; + } + const translatedPlatforms = platforms.map((platform) => { + const platformLang = UI_STRINGS[`game/platform_${platform}`].eng; + if (!platformLang) { + throw new Error(`Invalid platform "${platform}"`); + } + return platformLang; + }); return `A game for ${UI_STRINGS["util/join_names"].eng(translatedPlatforms)}`; }, }, @@ -146,7 +145,22 @@ export const UI_STRINGS = { }, "game/warnings": { eng: (platforms: GamePlatform[], contentWarning: string) => - `${UI_STRINGS["game/platforms"].eng(platforms)}. ${contentWarning}`, + platforms.length > 0 ? `${UI_STRINGS["game/platforms"].eng(platforms)}. ${contentWarning}` : contentWarning, + }, + "tag/total_works_with_tag": { + eng: (tag: string, storiesCount: number, gamesCount: number) => { + const content = []; + if (storiesCount > 0) { + content.push(UI_STRINGS["util/enumerate"].eng(storiesCount, "story", "stories")); + } + if (gamesCount > 0) { + content.push(UI_STRINGS["util/enumerate"].eng(gamesCount, "game", "games")); + } + if (content.length == 0) { + return `No works tagged with "${tag}".`; + } + return UI_STRINGS["util/capitalize"].eng(`${UI_STRINGS["util/join_names"].eng(content)} tagged with "${tag}".`); + }, }, } as const; diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro index 2bf2d7a..768c66b 100644 --- a/src/layouts/GameLayout.astro +++ b/src/layouts/GameLayout.astro @@ -27,17 +27,17 @@ const categorizedTags = Object.fromEntries( ), ), ); -const tags = props.tags.map<[string, string]>((tag) => { - const tagSlug = slug(tag); +const tags = props.tags.map<{ id: string; name: string }>((tag) => { + const id = slug(tag); if (!(tag in categorizedTags)) { console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`); - return [tagSlug, tag]; + return { id, name: tag }; } if (categorizedTags[tag] == null) { console.warn(`No "${props.lang}" translation for tag "${tag}"`); - return [tagSlug, tag]; + return { id, name: tag }; } - return [tagSlug, categorizedTags[tag]!]; + return { id, name: categorizedTags[tag]! }; }); const thumbnail = props.thumbnail && @@ -217,10 +217,10 @@ const thumbnail = Tags </h2> <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2"> - {tags.map(([tagSlug, tagText]) => ( + {tags.map(({ id, name }) => ( <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none"> - <a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}> - {tagText} + <a class="hover:underline focus:underline" href={`/tags/${id}`}> + {name} </a> </li> ))} diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro index a820caf..a439c92 100644 --- a/src/layouts/StoryLayout.astro +++ b/src/layouts/StoryLayout.astro @@ -39,22 +39,22 @@ const categorizedTags = Object.fromEntries( ), ), ); -const tags = props.tags.map<[string, string]>((tag) => { +const tags = props.tags.map<{ id: string; name: string }>((tag) => { const tagSlug = slug(tag); if (!(tag in categorizedTags)) { console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`); - return [tagSlug, tag]; + return { id: tagSlug, name: tag }; } if (categorizedTags[tag] == null) { console.warn(`No "${props.lang}" translation for tag "${tag}"`); - return [tagSlug, tag]; + return { id: tagSlug, name: tag }; } - return [tagSlug, categorizedTags[tag]!]; + return { id: tagSlug, name: categorizedTags[tag]! }; }); const thumbnail = props.thumbnail && (await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight })); -const wordCount = props.wordCount ? `${props.wordCount}` : "???"; +const wordCount = props.wordCount?.toString(); --- <BaseLayout pageTitle={props.title}> @@ -352,10 +352,10 @@ const wordCount = props.wordCount ? `${props.wordCount}` : "???"; {t(props.lang, "story/tags")} </h2> <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2"> - {tags.map(([tagSlug, tagText]) => ( + {tags.map(({ id, name }) => ( <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none"> - <a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}> - {tagText} + <a class="hover:underline focus:underline" href={`/tags/${id}`}> + {name} </a> </li> ))} diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts index 431549f..32fcdfb 100644 --- a/src/pages/api/export-story/[...slug].ts +++ b/src/pages/api/export-story/[...slug].ts @@ -1,10 +1,11 @@ import type { APIRoute, GetStaticPaths } from "astro"; import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content"; -import type { Lang, Website } from "../../../content/config"; +import type { Website } from "../../../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"; interface ExportWebsiteInfo { website: Website; @@ -97,10 +98,7 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b return !preferredLink || preferredLink == website; } -function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, anonymousFallback: string): string { - if (user.data.isAnonymous) { - return anonymousFallback; - } +function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName): string { switch (website) { case "eka": if ("eka" in user.data.links) { @@ -155,13 +153,6 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa ); } -function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string { - if (user.data.isAnonymous) { - return getUsernameForLang(anonymousUser, lang); - } - return getUsernameForLang(user, lang); -} - type Props = { story: CollectionEntry<"stories">; }; @@ -180,23 +171,21 @@ export const getStaticPaths: GetStaticPaths = async () => { })); }; -const ANONYMOUS_USER = await getEntry("users", "anonymous"); - export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => { const { lang } = story.data; const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters); 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 anonymousFallback = getNameForUser(ANONYMOUS_USER, ANONYMOUS_USER, lang); const description = Object.fromEntries( WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => { - const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback); + const u = (user: CollectionEntry<"users">) => + isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website); const storyDescription = ( [ story.data.description, - `*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`, + `*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}*`, t( lang, "export_story/writing", @@ -236,10 +225,10 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) = `${t( lang, "story/authors", - authorsList.map((author) => getNameForUser(author, ANONYMOUS_USER, lang)), + authorsList.map((author) => getUsernameForLang(author, lang)), )}\n` + - (commissioner ? `${t(lang, "story/commissioned_by", getNameForUser(commissioner, ANONYMOUS_USER, lang))}\n` : "") + - (requester ? `${t(lang, "story/requested_by", getNameForUser(requester, ANONYMOUS_USER, lang))}\n` : ""); + (commissioner ? `${t(lang, "story/commissioned_by", getUsernameForLang(commissioner, lang))}\n` : "") + + (requester ? `${t(lang, "story/requested_by", getUsernameForLang(requester, lang))}\n` : ""); const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}` .replaceAll(/\n\n\n+/g, "\n\n") diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts index 2674c76..26c0d62 100644 --- a/src/pages/feed.xml.ts +++ b/src/pages/feed.xml.ts @@ -49,7 +49,7 @@ export const GET: APIRoute = async ({ site }) => { pubDate: toNoonUTCDate(data.pubDate!), link: `/stories/${slug}`, description: - `${t(data.lang, "story/warnings", data.wordCount || "???", data.contentWarning.trim())}\n\n${markdownToPlaintext(data.description)}` + `${t(data.lang, "story/warnings", data.wordCount, data.contentWarning.trim())}\n\n${markdownToPlaintext(data.description)}` .replaceAll(/[\n ]+/g, " ") .trim(), categories: ["story"], diff --git a/src/pages/index.astro b/src/pages/index.astro index 5640b76..2ac7fca 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -28,7 +28,7 @@ const latestItems: LatestItemsEntry[] = [ thumbnail: story.data.thumbnail, href: `/stories/${story.slug}`, title: story.data.title, - altText: t(story.data.lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim()), + altText: t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim()), pubDate: story.data.pubDate!, })), games.map<LatestItemsEntry>((game) => ({ diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro index 843a966..52871cf 100644 --- a/src/pages/stories/[page].astro +++ b/src/pages/stories/[page].astro @@ -71,12 +71,7 @@ const totalPages = Math.ceil(page.total / page.size); <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`} - title={t( - story.data.lang, - "story/warnings", - story.data.wordCount || "???", - story.data.contentWarning.trim(), - )} + title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())} > {story.data.thumbnail ? ( <div class="flex aspect-square max-w-[192px] justify-center"> diff --git a/src/pages/tags.astro b/src/pages/tags.astro index 6fd2c9c..f1e4bb5 100644 --- a/src/pages/tags.astro +++ b/src/pages/tags.astro @@ -4,6 +4,12 @@ import { slug } from "github-slugger"; import GalleryLayout from "../layouts/GalleryLayout.astro"; import { markdownToPlaintext } from "../utils/markdown_to_plaintext"; +interface Tag { + id: string; + name: string; + description?: string; +} + const [stories, games, tagCategories] = await Promise.all([ getCollection("stories"), getCollection("games"), @@ -32,24 +38,30 @@ const seriesCollection = await getCollection("series"); const uncategorizedTagsSet = new Set(tagsSet); const categorizedTags = tagCategories - .sort((a, b) => a.data.index - b.data.index) + .sort((a, b) => { + if (a.data.index == b.data.index) { + throw new Error( + `Found tag categories with same index value ${a.data.index} ("${a.data.name}", "${b.data.name}")`, + ); + } + return a.data.index - b.data.index; + }) .map((category) => { - const tagList = category.data.tags.map(({ name, description }) => { + const tagList = category.data.tags.map<Tag>(({ name, description }) => { description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ").trim(); - return (typeof name === "string" ? { name, description } : { name: name["eng"]!, description }) as { - name: string; - description?: string; - }; + const tag = typeof name === "string" ? name : name["eng"]; + const id = slug(tag); + return { id, name: tag, description }; }); tagList.forEach(({ name }, index) => { if (index !== tagList.findLastIndex(({ name: otherTag }) => name == otherTag)) { throw new Error(`Duplicated tag "${name}" found in multiple tag-categories lists`); } }); - return [ - category.data.name, - category.id, - tagList.filter(({ name }) => { + return { + name: category.data.name, + id: category.id, + tags: tagList.filter(({ name }) => { if (draftOnlyTagsSet.has(name)) { console.log(`Omitting draft-only tag "${name}"`); return false; @@ -59,7 +71,7 @@ const categorizedTags = tagCategories } return true; }), - ] as [string, string, { name: string; description?: string }[]]; + }; }); if (uncategorizedTagsSet.size > 0) { @@ -92,16 +104,16 @@ if (uncategorizedTagsSet.size > 0) { </ul> </section> { - categorizedTags.map(([category, categorySlug, tagList]) => + categorizedTags.map(({ name: category, id: categoryId, tags: tagList }) => tagList.length > 0 ? ( - <section class="my-2" aria-labelledby={`category-${categorySlug}`}> - <h2 id={`category-${categorySlug}`} class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100"> + <section class="my-2" aria-labelledby={`category-${categoryId}`}> + <h2 id={`category-${categoryId}`} class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100"> {category} </h2> <ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2"> - {tagList.map(({ name, description }) => ( + {tagList.map(({ id, name, description }) => ( <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white"> - <a class="hover:underline focus:underline" href={`/tags/${slug(name)}`} title={description}> + <a class="hover:underline focus:underline" href={`/tags/${id}`} title={description}> {name} </a> </li> diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro index c49006d..d4e3d76 100644 --- a/src/pages/tags/[slug].astro +++ b/src/pages/tags/[slug].astro @@ -7,6 +7,7 @@ import { slug } from "github-slugger"; import GalleryLayout from "../../layouts/GalleryLayout.astro"; import Prose from "../../components/Prose.astro"; import { t } from "../../i18n"; +import { DEFAULT_LANG } from "../../i18n"; type Props = { tag: string; @@ -39,31 +40,31 @@ export const getStaticPaths: GetStaticPaths = async () => { tags.add(tag); }); }); - const tagDescriptions = Object.fromEntries( - tagCategories.flatMap((category) => - category.data.tags.reduce( - (acc, { name, description, related }) => { - if (related) { - related = related.filter((relatedTag) => { - if (relatedTag == name) { - console.warn(`Tag "${name}" should not have itself as a related tag; removing`); - return false; - } - if (!tags.has(relatedTag)) { - console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`); - return false; - } - return true; - }); - } - acc.push( - typeof name === "string" ? [name, { description, related }] : [name["eng"]!, { description, related }], - ); - return acc; - }, - [] as [string, { description?: string; related?: string[] }][], - ), - ), + const tagDescriptions = tagCategories.reduce( + (acc, category) => { + category.data.tags.forEach(({ name, description, related }) => { + if (related) { + related = related.filter((relatedTag) => { + if (relatedTag == name) { + console.warn(`Tag "${name}" should not have itself as a related tag; removing`); + return false; + } + if (!tags.has(relatedTag)) { + console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`); + return false; + } + return true; + }); + } + const key = typeof name === "string" ? name : name["eng"]; + if (key in acc) { + throw new Error(`Duplicated tag "${key}" found in multiple tag-categories lists`); + } + acc[key] = { description, related }; + }); + return acc; + }, + {} as Record<string, { description?: string; related?: string[] }>, ); return [...tags] .filter((tag) => !seriesTags.has(tag)) @@ -71,8 +72,8 @@ export const getStaticPaths: GetStaticPaths = async () => { params: { slug: slug(tag) } satisfies Params, props: { tag, - description: tagDescriptions[tag].description, - related: tagDescriptions[tag].related, + description: tagDescriptions[tag]?.description, + related: tagDescriptions[tag]?.related, stories: stories .filter((story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag)) .sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()), @@ -85,23 +86,9 @@ export const getStaticPaths: GetStaticPaths = async () => { const { tag, description, stories, games, related } = Astro.props; if (!description) { - console.log(`Tag "${tag}" has no description`); -} -const count = stories.length + games.length; -let totalWorksWithTag: string = ""; -if (count == 1) { - if (stories.length == 1) { - totalWorksWithTag = `One story tagged with "${tag}".`; - } else if (games.length == 1) { - totalWorksWithTag = `One game tagged with "${tag}".`; - } -} else if (stories.length == 0) { - totalWorksWithTag = `${games.length} games tagged with "${tag}".`; -} else if (games.length == 0) { - totalWorksWithTag = `${stories.length} stories tagged with "${tag}".`; -} else { - totalWorksWithTag = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`; + console.warn(`Tag "${tag}" has no description`); } +const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stories.length, games.length); --- <GalleryLayout pageTitle={`Works tagged "${tag}"`}> @@ -132,12 +119,7 @@ if (count == 1) { <a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`} - title={t( - story.data.lang, - "story/warnings", - story.data.wordCount || "???", - story.data.contentWarning.trim(), - )} + title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())} > {story.data.thumbnail ? ( <div class="flex aspect-square max-w-[192px] justify-center"> diff --git a/src/utils/get_username_for_lang.ts b/src/utils/get_username_for_lang.ts index df55190..e9c6871 100644 --- a/src/utils/get_username_for_lang.ts +++ b/src/utils/get_username_for_lang.ts @@ -1,12 +1,15 @@ import type { CollectionEntry } from "astro:content"; -import type { Lang } from "../content/config"; +import { DEFAULT_LANG, type Lang } from "../content/config"; export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string { - if (user.data.nameLang) { - if (user.data.nameLang[lang]) { - return user.data.nameLang[lang]; + if (user.data.lang[DEFAULT_LANG]) { + if (user.data.lang[lang]) { + return user.data.lang[lang]; } throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`); } + if (lang !== DEFAULT_LANG) { + console.warn(`User "${user.data.name}" ("${user.id}") has no "lang" property`); + } return user.data.name; } diff --git a/src/utils/is_anonymous_user.ts b/src/utils/is_anonymous_user.ts new file mode 100644 index 0000000..3f1142a --- /dev/null +++ b/src/utils/is_anonymous_user.ts @@ -0,0 +1,6 @@ +import type { CollectionEntry } from "astro:content"; +import { ANONYMOUS_USER } from "../content/config"; + +const ANONYMOUS_USER_ID: CollectionEntry<"users">["id"] = ANONYMOUS_USER; + +export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id == ANONYMOUS_USER_ID;