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
This commit is contained in:
Bad Manners 2024-07-26 15:48:57 -03:00
parent 579e5879e1
commit 17ef8c652c
34 changed files with 223 additions and 221 deletions

4
package-lock.json generated
View file

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

View file

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

View file

@ -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>
)
}

View file

@ -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(),
}),

View file

@ -38,7 +38,7 @@ tags:
- non-binary prey
- micro prey
- soul vore
- implied perma endo
- long-term endo
---
<iframe

View file

@ -27,7 +27,7 @@ tags:
- unwilling prey
- willing prey
- size difference
- long-term endo
- implied perma endo
- straight sex
---

View file

@ -29,7 +29,7 @@ tags:
- willing prey
- semi-willing prey
- similar size
- perma endo
- implied perma endo
- straight sex
- gay sex
- hyper

View file

@ -26,7 +26,7 @@ tags:
- willing predator
- unwilling prey
- similar size
- perma endo
- implied perma endo
- straight sex
- gay sex
- orgy

View file

@ -26,7 +26,8 @@ tags:
- willing prey
- size difference
- masturbation
- perma endo
- long-term endo
- implied perma endo
- flash fiction
---

View file

@ -26,7 +26,7 @@ tags:
- willing predator
- unwilling prey
- size difference
- perma endo
- implied perma endo
- request
requester: dee-lumeni
copyrightedCharacters:

View file

@ -26,6 +26,7 @@ tags:
- semi-willing predator
- willing predator
- willing prey
- long-term endo
- same size
- hyper
- inflation

View file

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

View file

@ -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).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,5 @@
name: Anonymous
nameLang:
lang:
eng: anonymous
tok: jan pi nimi ala
isAnonymous: true
links: {}

View file

@ -1,5 +1,5 @@
name: Bad Manners
nameLang:
lang:
eng: Bad Manners
tok: nasin ike Pemene
avatar: /src/assets/images/logo_bm.png

View file

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

View file

@ -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>
))}

View file

@ -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>
))}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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