import { defineCollection, reference, z } from "astro:content"; // Constants export const DEFAULT_LANG = "en"; export const ANONYMOUS_USER_ID = "anonymous"; export const SELF_USER_ID = "bad-manners"; // Transformers function parseRegex(regex: RegExp) { return (url: string, ctx: z.RefinementCtx) => { const match = regex.exec(url); if (!match?.groups) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `"${ctx.path}" did not match regex`, }); return z.NEVER; } return match.groups as R; }; } // Schema definitions /** Record of website links for a user. * * For each entry, you can enter a URL for the value, or a pre-parsed object * containing a link and a username (except for `website`). */ const websiteLinks = z.object({ website: z.object({ link: z.string().url() }).or( z .string() .url() .transform((link) => { link; }), ), eka: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>( /^(?:https?:\/\/)?(?:www\.)?aryion\.com\/g4\/user\/(?[^\/]+)\/?$/, )(link, ctx); return { link, username }; }), ), furaffinity: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>( /^(?:https?:\/\/)?(?:www\.)?furaffinity\.net\/user\/(?[^\/]+)\/?$/, )(link, ctx); return { link, username }; }), ), weasyl: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>( /^(?:https?:\/\/)?(?:www\.)?weasyl\.com\/\~(?[^\/]+)\/?$/, )(link, ctx); return { link, username }; }), ), inkbunny: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>( /^(?:https?:\/\/)?(?:www\.)?inkbunny\.net\/(?[^\/]+)\/?$/, )(link, ctx); return { link, username }; }), ), sofurry: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>(/^(?:https?:\/\/)?(?[^\.]+).sofurry.com\/?$/)( link, ctx, ); return { link, username }; }), ), sofurrybeta: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>( /^(?:https?:\/\/)?sofurrybeta.com\/u\/(?[^\/]+)\/?$/, )(link, ctx); return { link, username }; }), ), mastodon: z .object({ link: z.string().url(), username: z.string().regex(/^[^@]+@[^@]+$/) }) .or( z.string().transform((link, ctx) => { const { username, instance } = parseRegex<{ username: string; instance: string }>( /^(?:https?:\/\/)(?(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/(?:@|users\/)(?[a-zA-Z][a-zA-Z0-9_-]+)\/?$/, )(link, ctx); return { link, username: `${username}@${instance}` }; }), ) .transform(({ link, username }) => { const i = username.indexOf("@"); return { link, username, handle: username.slice(0, i), instance: username.slice(i + 1) }; }), twitter: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>( /^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/@?(?[^\/]+)\/?$/, )(link, ctx); return { link, username }; }), ), bluesky: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>( /^(?:https?:\/\/)?bsky\.app\/profile\/(?[^\/]+)\/?$/, )(link, ctx); return { link, username }; }), ), itaku: z.object({ link: z.string().url(), username: z.string() }).or( z.string().transform((link, ctx) => { const { username } = parseRegex<{ username: string }>( /^(?:https?:\/\/)?(?:www\.)?itaku\.ee\/profile\/(?[^\/]+)\/?$/, )(link, ctx); return { link, username }; }), ), }); /** Available languages. See https://r12a.github.io/app-subtags/ */ const lang = z.enum(["en", "tok"]).default(DEFAULT_LANG); /** Platforms for a game. */ const platform = z.enum(["web", "windows", "linux", "macos", "android", "ios"]); const userList = z .array(reference("users")) .refine((value) => value.length > 0, `user list cannot be empty`) .or(reference("users").transform((user) => [user])); /** A record of the format `{"Character name": "user-id"}`. * * An empty character name `""` indicates that all characters are copyrighted * by a certain user. */ const copyrightedCharacters = z .record(z.string(), reference("users")) .refine( (value) => !("" in value) || Object.keys(value).length === 1, `"copyrightedCharacters" cannot mix empty catch-all key with other keys`, ) .default({}); /** A record with a mandatory `en` value and optional strings for the remaining languages. */ const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string())); /** Common attributes for published content (stories + games). */ const publishedContent = z .object({ // Required parameters title: z.string(), authors: userList, // Required parameters, but optional for drafts (isDraft === true) pubDate: z .date() .transform((date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)) .optional(), description: z.string().trim(), tags: z.array(z.string()), // Optional parameters isDraft: z.boolean().default(false), isAgeRestricted: z.boolean().default(true), isFeatured: z.boolean().default(false), relatedStories: z.array(reference("stories")).default([]), relatedGames: z.array(reference("games")).default([]), relatedBlogPosts: z.array(reference("blog")).default([]), lang: lang, series: reference("series").optional(), posts: z .object({ eka: z.string().transform((link, ctx) => { const { postId } = parseRegex<{ postId: string }>( /^(?:https?:\/\/)(?:www\.)?aryion\.com\/g4\/view\/(?[1-9]\d*)\/?$/, )(link, ctx); return { link, postId }; }), furaffinity: z.string().transform((link, ctx) => { const { postId } = parseRegex<{ postId: string }>( /^(?:https?:\/\/)(?:www\.)?furaffinity\.net\/view\/(?[1-9]\d*)\/?$/, )(link, ctx); return { link, postId }; }), weasyl: z.string().transform((link, ctx) => { const { user, postId } = parseRegex<{ user: string; postId: string }>( /^(?:https?:\/\/)(?:www\.)?weasyl\.com\/~(?[a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/(?[1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/, )(link, ctx); return { link, user, postId }; }), inkbunny: z.string().transform((link, ctx) => { const { postId } = parseRegex<{ postId: string }>( /^(?:https?:\/\/)(?:www\.)?inkbunny\.net\/s\/(?[1-9]\d*)\/?$/, )(link, ctx); return { link, postId }; }), sofurry: z.string().transform((link, ctx) => { const { postId } = parseRegex<{ postId: string }>( /^(?:https?:\/\/)www\.sofurry\.com\/view\/(?[1-9]\d*)\/?$/, )(link, ctx); return { link, postId }; }), sofurrybeta: z.string().transform((link, ctx) => { const { postId } = parseRegex<{ postId: string }>( /^(?:https?:\/\/)sofurrybeta\.com\/s\/(?[a-zA-Z0-9]+)\/?$/, )(link, ctx); return { link, postId }; }), itch: z.string().transform((link, ctx) => { const { user, postId } = parseRegex<{ user: string; postId: string }>( /^(?:https?:\/\/)(?[a-z-]+)\.itch\.io\/(?[a-z0-9_-]+)\/?$/, )(link, ctx); return { link, user, postId }; }), twitter: z.string().transform((link, ctx) => { const { user, postId } = parseRegex<{ user: string; postId: string }>( /^(?:https?:\/\/)(?:www\.)?(?:twitter\.com|x\.com)\/(?[a-zA-Z0-9_-]+)\/status\/(?[1-9]\d*)\/?$/, )(link, ctx); return { link, user, postId }; }), bluesky: z.string().transform((link, ctx) => { const { user, postId } = parseRegex<{ user: string; postId: string }>( /^(?:https?:\/\/)bsky\.app\/profile\/(?(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/post\/(?[a-z0-9]+)\/?$/, )(link, ctx); return { link, user, postId }; }), mastodon: z.string().transform((link, ctx) => { const { instance, user, postId } = parseRegex<{ instance: string; user: string; postId: string }>( /^(?:https?:\/\/)(?(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@(?[a-zA-Z][a-zA-Z0-9_-]+)\/(?[1-9]\d*)\/?$/, )(link, ctx); return { link, instance, user, postId }; }), }) .partial() .default({}), blacklistedMastodonComments: z.array(z.string()).default([]), }) .refine( ({ posts, blacklistedMastodonComments }) => !blacklistedMastodonComments.length || posts.mastodon, `Cannot include "blacklistedMastodonComments" without a Mastodon post`, ) .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published content`) .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published content`) .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published content`); // Types export type Lang = z.output; export type UserWebsite = keyof z.input; export type GamePlatform = z.infer; export type CopyrightedCharacters = z.infer; export type PublishedContent = z.infer; export type PostWebsite = keyof PublishedContent["posts"]; export type Posts = PublishedContent["posts"]; // Content collections const storiesCollection = defineCollection({ type: "content", schema: ({ image }) => z .object({ // Required parameters, but optional for drafts (isDraft === true) contentWarning: z.string().trim(), wordCount: z.number().int().optional(), thumbnail: image().optional(), // Optional parameters thumbnailWidth: z.number().int().optional(), thumbnailHeight: z.number().int().optional(), prev: reference("stories").nullish(), next: reference("stories").nullish(), copyrightedCharacters: copyrightedCharacters, shortTitle: z.string().optional(), commissioners: userList.optional(), requesters: userList.optional(), summary: z.string().trim().optional(), }) .and(publishedContent) .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published story`) .refine( ({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published story`, ) .refine(({ isDraft, wordCount }) => isDraft || wordCount, `Missing "wordCount" for published story`), }); const gamesCollection = defineCollection({ type: "content", schema: ({ image }) => z .object({ // Required parameters, but optional for drafts (isDraft === true) contentWarning: z.string().trim(), platforms: z.array(platform).default([]), thumbnail: image().optional(), // Optional parameters thumbnailWidth: z.number().int().optional(), thumbnailHeight: z.number().int().optional(), prev: reference("games").nullish(), next: reference("games").nullish(), copyrightedCharacters: copyrightedCharacters, }) .and(publishedContent) .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published game`) .refine(({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published game`) .refine(({ isDraft, platforms }) => isDraft || platforms.length, `Missing "platforms" for published game`), }); const blogCollection = defineCollection({ type: "content", schema: ({ image }) => z .object({ // Required parameters, but optional for drafts (isDraft === true) thumbnail: image().optional(), // Optional parameters thumbnailWidth: z.number().int().optional(), thumbnailHeight: z.number().int().optional(), prev: reference("blog").nullish(), next: reference("blog").nullish(), }) .and(publishedContent) .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published blog post`), }); // Data collections const usersCollection = defineCollection({ type: "data", schema: ({ image }) => z .object({ // Required parameters name: langRecord.or(z.string()), links: websiteLinks.partial(), // Optional parameters preferredLink: websiteLinks.keyof().nullish(), avatar: image().optional(), }) .refine( ({ links, preferredLink }) => !preferredLink || preferredLink in links, ({ preferredLink }) => ({ message: `"${preferredLink}" not defined in "links"`, path: ["preferredLink"], }), ), }); const seriesCollection = defineCollection({ type: "data", schema: z.object({ // Required parameters name: z.string(), link: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"link" must be a local URL`), }), }); const tagCategoriesCollection = defineCollection({ type: "data", schema: z.object({ // Required parameters name: z.string(), index: z.number().int(), tags: z .array( z.object({ name: langRecord.or(z.string()), description: langRecord.or(z.string().trim()).optional(), related: z.array(z.string()).default([]), }), ) .refine((tags) => tags.length, `"tags" cannot be empty`), }), }); export const collections = { stories: storiesCollection, games: gamesCollection, blog: blogCollection, users: usersCollection, series: seriesCollection, "tag-categories": tagCategoriesCollection, };