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:
parent
579e5879e1
commit
17ef8c652c
34 changed files with 223 additions and 221 deletions
package-lock.jsonpackage.json
src
components
content
config.ts
games
stories
accommodation.mdaddictive-additions.mdbetter-in-bully-batter.mdlatest-catch.mdoverzealous-zenko.mdteam-effort.md
tag-categories
1-types-of-vore.yaml10-recurring-characters.yaml2-body-types.yaml3-genders.yaml4-relative-size.yaml5-willingness.yaml6-vore-related-scenarios.yaml7-sexual-content.yaml8-other-kinks.yaml9-type-of-content.yaml
users
i18n
layouts
pages
utils
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
@ -38,7 +38,7 @@ tags:
|
|||
- non-binary prey
|
||||
- micro prey
|
||||
- soul vore
|
||||
- implied perma endo
|
||||
- long-term endo
|
||||
---
|
||||
|
||||
<iframe
|
||||
|
|
|
@ -27,7 +27,7 @@ tags:
|
|||
- unwilling prey
|
||||
- willing prey
|
||||
- size difference
|
||||
- long-term endo
|
||||
- implied perma endo
|
||||
- straight sex
|
||||
---
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ tags:
|
|||
- willing prey
|
||||
- semi-willing prey
|
||||
- similar size
|
||||
- perma endo
|
||||
- implied perma endo
|
||||
- straight sex
|
||||
- gay sex
|
||||
- hyper
|
||||
|
|
|
@ -26,7 +26,7 @@ tags:
|
|||
- willing predator
|
||||
- unwilling prey
|
||||
- similar size
|
||||
- perma endo
|
||||
- implied perma endo
|
||||
- straight sex
|
||||
- gay sex
|
||||
- orgy
|
||||
|
|
|
@ -26,7 +26,8 @@ tags:
|
|||
- willing prey
|
||||
- size difference
|
||||
- masturbation
|
||||
- perma endo
|
||||
- long-term endo
|
||||
- implied perma endo
|
||||
- flash fiction
|
||||
---
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ tags:
|
|||
- willing predator
|
||||
- unwilling prey
|
||||
- size difference
|
||||
- perma endo
|
||||
- implied perma endo
|
||||
- request
|
||||
requester: dee-lumeni
|
||||
copyrightedCharacters:
|
||||
|
|
|
@ -26,6 +26,7 @@ tags:
|
|||
- semi-willing predator
|
||||
- willing predator
|
||||
- willing prey
|
||||
- long-term endo
|
||||
- same size
|
||||
- hyper
|
||||
- inflation
|
||||
|
|
|
@ -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.
|
|
@ -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).
|
|
@ -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".
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -1,6 +1,5 @@
|
|||
name: Anonymous
|
||||
nameLang:
|
||||
lang:
|
||||
eng: anonymous
|
||||
tok: jan pi nimi ala
|
||||
isAnonymous: true
|
||||
links: {}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
name: Bad Manners
|
||||
nameLang:
|
||||
lang:
|
||||
eng: Bad Manners
|
||||
tok: nasin ike Pemene
|
||||
avatar: /src/assets/images/logo_bm.png
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
6
src/utils/is_anonymous_user.ts
Normal file
6
src/utils/is_anonymous_user.ts
Normal 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;
|
Loading…
Add table
Reference in a new issue