Several minor improvements to typing and misc.

- Improved schema validation
- Move username parsing and other validators to schema types
- Fix astro check command
- Add JSON/YAML schema validation for data collections
- Update licenses
- Remove deployment script in favor of rsync
- Prevent unsanitized input in export-story script
- Change "eng" language to "en", per BCP47
- Clean up i18n keys and add aria attributes
- Improve MastodonComments behavior on no-JS browsers
This commit is contained in:
Bad Manners 2024-08-07 19:25:50 -03:00
parent fe908a4989
commit 7bb8a952ef
54 changed files with 1005 additions and 962 deletions

View file

@ -2,89 +2,214 @@ import { defineCollection, reference, z } from "astro:content";
// Constants
export const WEBSITE_LIST = [
"website",
"eka",
"furaffinity",
"weasyl",
"inkbunny",
"sofurry",
"mastodon",
"twitter",
"bluesky",
"itaku",
] as const;
export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const;
export const DEFAULT_LANG = "eng";
export const DEFAULT_AUTHOR_ID = "bad-manners";
export const DEFAULT_LANG = "en";
export const ANONYMOUS_USER_ID = "anonymous";
// Validators
const ekaPostUrlRegex = /^(?:https:\/\/)(?:www\.)?aryion\.com\/g4\/view\/([1-9]\d*)\/?$/;
const furaffinityPostUrlRegex = /^(?:https:\/\/)(?:www\.)?furaffinity\.net\/view\/([1-9]\d*)\/?$/;
const weasylPostUrlRegex =
/^(?:https:\/\/)(?:www\.)?weasyl\.com\/~([a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/([1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/;
const inkbunnyPostUrlRegex = /^(?:https:\/\/)(?:www\.)?inkbunny\.net\/s\/([1-9]\d*)\/?$/;
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,
`"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
const trimText = (text: string) => text.trim();
const adjustDateForUTCOffset = (date: Date) =>
new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
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]!,
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 - for any key apart
* from `website` - a pre-parsed object containing the link and username.
*/
const websiteLinks = z.object({
website: 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 };
}),
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
const lang = z.enum(["eng", "tok"]).default(DEFAULT_LANG);
const website = z.enum(WEBSITE_LIST);
const platform = z.enum(GAME_PLATFORMS);
const mastodonPost = z
.object({
instance: z.string(),
user: z.string(),
postId: z.string(),
})
.or(z.string().transform(parseMastodonPostUrl));
const userList = z
.array(reference("users"))
.or(reference("users").transform((user) => [user]))
.refine((value) => value.length > 0, `user list cannot be empty`);
const authors = userList.default([DEFAULT_AUTHOR_ID]);
const copyrightedCharacters = z
.record(z.string(), reference("users"))
.default({})
.refine(...refineCopyrightedCharacters);
// { eng: string, tok?: string, ... }
const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()));
export type Lang = z.output<typeof lang>;
export type Website = z.infer<typeof website>;
export type Website = keyof z.input<typeof websiteLinks>;
export type GamePlatform = z.infer<typeof platform>;
export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
@ -93,79 +218,52 @@ export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
const storiesCollection = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
// Required
title: z.string(),
wordCount: z.number().int().optional(),
contentWarning: z.string().transform(trimText),
description: z.string().transform(trimText),
tags: z.array(z.string()),
// Optional
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
isDraft: z.boolean().default(false),
shortTitle: z.string().optional(),
authors,
summary: z.string().transform(trimText).optional(),
thumbnail: image().optional(),
thumbnailWidth: z.number().int().optional(),
thumbnailHeight: z.number().int().optional(),
series: reference("series").optional(),
commissioner: userList.optional(),
requester: userList.optional(),
copyrightedCharacters: copyrightedCharacters,
lang,
prev: reference("stories").nullish(),
next: reference("stories").nullish(),
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
posts: z
.object({
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({}),
}),
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
title: z.string(),
contentWarning: z.string().transform(trimText),
description: z.string().transform(trimText),
platforms: z.array(platform).refine((platforms) => platforms.length > 0, `"platforms" cannot be empty`),
tags: z.array(z.string()),
// Optional
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
isDraft: z.boolean().default(false),
authors,
thumbnail: image().optional(),
thumbnailWidth: z.number().int().optional(),
thumbnailHeight: z.number().int().optional(),
series: reference("series").optional(),
copyrightedCharacters: copyrightedCharacters,
lang,
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
posts: z
.object({
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({}),
}),
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(),
})
.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
@ -175,12 +273,11 @@ const usersCollection = defineCollection({
schema: ({ image }) =>
z
.object({
// Required
name: z.string(),
links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
// Optional
preferredLink: website.nullish(),
lang: langRecord.optional(),
// Required parameters
name: langRecord.or(z.string()),
links: websiteLinks.partial(),
// Optional parameters
preferredLink: websiteLinks.keyof().nullish(),
avatar: image().optional(),
})
.refine(
@ -195,7 +292,7 @@ const usersCollection = defineCollection({
const seriesCollection = defineCollection({
type: "data",
schema: z.object({
// Required
// Required parameters
name: z.string(),
url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`),
}),
@ -204,16 +301,18 @@ const seriesCollection = defineCollection({
const tagCategoriesCollection = defineCollection({
type: "data",
schema: z.object({
// Required
// Required parameters
name: z.string(),
index: z.number().int(),
tags: z.array(
z.object({
name: z.union([z.string(), langRecord]),
description: z.string().optional(),
related: z.array(z.string()).optional(),
}),
),
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`),
}),
});