import { defineCollection, reference, z } from "astro:content"; // Constants export const DEFAULT_LANG = "en"; export const ANONYMOUS_USER_ID = "anonymous"; // Transformers function parseRegex<R extends { [key: string]: string }>(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\/(?<username>[^\/]+)\/?$/, )(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\/(?<username>[^\/]+)\/?$/, )(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\/\~(?<username>[^\/]+)\/?$/, )(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\/(?<username>[^\/]+)\/?$/, )(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?:\/\/)?(?<username>[^\.]+).sofurry.com\/?$/)( 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?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/(?:@|users\/)(?<username>[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)\/@?(?<username>[^\/]+)\/?$/, )(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\/(?<username>[^\/]+)\/?$/, )(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\/(?<username>[^\/]+)\/?$/, )(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 of the format `{ en: string, tok?: string, ... }`. */ 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, contentWarning: z.string().trim(), // 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), relatedStories: z.array(reference("stories")).default([]), relatedGames: z.array(reference("games")).default([]), lang: lang, copyrightedCharacters: copyrightedCharacters, series: reference("series").optional(), posts: z .object({ eka: z.string().transform((link, ctx) => { const { postId } = parseRegex<{ postId: string }>( /^(?:https?:\/\/)(?:www\.)?aryion\.com\/g4\/view\/(?<postId>[1-9]\d*)\/?$/, )(link, ctx); return { link, postId }; }), furaffinity: z.string().transform((link, ctx) => { const { postId } = parseRegex<{ postId: string }>( /^(?:https?:\/\/)(?:www\.)?furaffinity\.net\/view\/(?<postId>[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\/~(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/(?<postId>[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\/(?<postId>[1-9]\d*)\/?$/, )(link, ctx); return { link, postId }; }), sofurry: z.string().transform((link, ctx) => { const { postId } = parseRegex<{ postId: string }>( /^(?:https?:\/\/)www\.sofurry\.com\/view\/(?<postId>[1-9]\d*)\/?$/, )(link, ctx); return { link, postId }; }), twitter: z.string().transform((link, ctx) => { const { user, postId } = parseRegex<{ user: string; postId: string }>( /^(?:https?:\/\/)(?:www\.)?(?:twitter\.com|x\.com)\/(?<user>[a-zA-Z0-9_-]+)\/status\/(?<postId>[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\/(?<user>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/post\/(?<postId>[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?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/(?<postId>[1-9]\d*)\/?$/, )(link, ctx); return { link, instance, user, postId }; }), }) .partial() .default({}), }); // Types export type Lang = z.output<typeof lang>; export type Website = keyof z.input<typeof websiteLinks>; export type GamePlatform = z.infer<typeof platform>; export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>; export type PublishedContent = z.infer<typeof publishedContent>; // Content collections const storiesCollection = defineCollection({ type: "content", schema: ({ image }) => z .object({ // Required parameters, but optional for drafts (isDraft == true) wordCount: z.number().int().optional(), thumbnail: image().optional(), // Optional parameters shortTitle: z.string().optional(), commissioner: userList.optional(), requester: userList.optional(), summary: z.string().trim().optional(), thumbnailWidth: z.number().int().optional(), thumbnailHeight: z.number().int().optional(), prev: reference("stories").nullish(), next: reference("stories").nullish(), }) .and(publishedContent) .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published story`) .refine( ({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published story`, ) .refine(({ isDraft, wordCount }) => isDraft || wordCount, `Missing "wordCount" for published story`) .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published story`) .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published story`) .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published story`), }); const gamesCollection = defineCollection({ type: "content", schema: ({ image }) => z .object({ // Required parameters, but optional for drafts (isDraft == true) 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(), }) .and(publishedContent) .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published game`) .refine(({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published game`) .refine(({ isDraft, platforms }) => isDraft || platforms.length, `Missing "platforms" for published game`) .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published game`) .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published game`) .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published game`), }); // 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(), url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" 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: 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, users: usersCollection, series: seriesCollection, "tag-categories": tagCategoriesCollection, };