336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
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,
|
|
};
|