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:
parent
fe908a4989
commit
7bb8a952ef
54 changed files with 1005 additions and 962 deletions
|
|
@ -1 +0,0 @@
|
|||
../assets/LICENSE
|
||||
|
|
@ -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`),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ posts:
|
|||
inkbunny: https://inkbunny.net/s/3262911
|
||||
sofurry: https://www.sofurry.com/view/2109688
|
||||
weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game
|
||||
bluesky: https://bsky.app/profile/badmanners.xyz/post/3kmigrf5q2x24
|
||||
mastodon: https://meow.social/@BadManners/112009918919441027
|
||||
tags:
|
||||
- oral vore
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
slug: playing-it-safe
|
||||
title: Playing It Safe
|
||||
pubDate: 2024-08-08
|
||||
isDraft: true
|
||||
authors: bad-manners
|
||||
wordCount: 9900
|
||||
contentWarning: >
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ posts:
|
|||
inkbunny: https://inkbunny.net/s/3283508
|
||||
sofurry: https://www.sofurry.com/view/2118138
|
||||
weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident
|
||||
bluesky: https://bsky.app/profile/badmanners.xyz/post/3kok52wijz32c
|
||||
mastodon: https://meow.social/@BadManners/112157812554023271
|
||||
tags:
|
||||
- anthro predator
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: Types of vore
|
||||
index: 1
|
||||
tags:
|
||||
- name: { eng: oral vore, tok: moku musi kepeken uta }
|
||||
- name: { en: oral vore, tok: moku musi kepeken uta }
|
||||
description: Scenarios where prey are consumed by the predator through their mouth.
|
||||
- name: anal vore
|
||||
description: Scenarios where prey are consumed by the predator through their butt/anus.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ tags:
|
|||
description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
|
||||
- name: taur predator
|
||||
description: Scenarios where at least one of the predators is a multi-legged centaur-like creature, with an animal lower body and anthropomorphic upper body.
|
||||
- name: { eng: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
|
||||
- name: { en: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
|
||||
description: Scenarios where the body type of at least one of the predators is left ambiguous.
|
||||
- name: human prey
|
||||
description: Scenarios where at least one of the prey is a human person.
|
||||
|
|
@ -15,5 +15,5 @@ tags:
|
|||
description: Scenarios where at least one of the prey is an anthropomorphic animal, i.e. generally regarded as a "furry".
|
||||
- name: feral prey
|
||||
description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
|
||||
- name: { eng: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale }
|
||||
- name: { en: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale }
|
||||
description: Scenarios where the body type of at least one of the predators is left ambiguous.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ tags:
|
|||
description: Scenarios where at least one of the predators is a woman and/or female-presenting.
|
||||
- name: non-binary predator
|
||||
description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not.
|
||||
- name: { eng: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije }
|
||||
- name: { en: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije }
|
||||
description: Scenarios where the gender at least one of the predators is left ambiguous.
|
||||
- name: male prey
|
||||
description: Scenarios where at least one of the prey is a man and/or male-presenting.
|
||||
|
|
@ -27,5 +27,5 @@ tags:
|
|||
- female prey
|
||||
- name: non-binary prey
|
||||
description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not.
|
||||
- name: { eng: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije }
|
||||
- name: { en: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije }
|
||||
description: Scenarios where the gender at least one of the predators is left ambiguous.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: Willingness
|
||||
index: 5
|
||||
tags:
|
||||
- name: { eng: willing predator, tok: jan pi wawa mute li wile e moku musi }
|
||||
- name: { en: 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.
|
||||
- name: semi-willing predator
|
||||
description: Scenarios where the willingness of at least one of the predators might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous.
|
||||
|
|
@ -13,7 +13,7 @@ tags:
|
|||
description: Scenarios where at least one of the prey participates in vore willingly.
|
||||
- name: semi-willing prey
|
||||
description: Scenarios where the willingness of at least one of the prey might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous.
|
||||
- name: { eng: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi }
|
||||
- name: { en: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi }
|
||||
description: Scenarios where at least one of the prey participates in vore unwillingly.
|
||||
- name: asleep prey
|
||||
description: Scenarios where at least one of the predators participates in vore while asleep.
|
||||
|
|
|
|||
|
|
@ -66,4 +66,4 @@ tags:
|
|||
- name: soul vore
|
||||
description: Scenarios where predators consume a soul instead of their prey's body.
|
||||
- name: Vore Day
|
||||
description: Stories created in commemoration of Vore Day, which is celebrated on August 8ᵗʰ, and/or are set in said day.
|
||||
description: Stories created in commemoration of Vore Day, which is celebrated on August 8th, and/or are set in said day.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ tags:
|
|||
description: Stories made by someone else's request, as a gift.
|
||||
- name: commission
|
||||
description: Stories made as part of a commission to someone else.
|
||||
- name: { eng: flash fiction, tok: lipu lili }
|
||||
- name: { en: flash fiction, tok: lipu lili }
|
||||
description: Short-format stories of no more than 2,500 words.
|
||||
- name: toki pona
|
||||
description: Stories written in toki pona, the language of good.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
name: Anonymous
|
||||
lang:
|
||||
eng: anonymous
|
||||
name:
|
||||
en: anonymous
|
||||
tok: jan pi nimi ala
|
||||
links: {}
|
||||
preferredLink: ~
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: Asof Yeun
|
||||
links:
|
||||
eka: https://aryion.com/g4/user/asofyeun
|
||||
furaffinity: https://www.furaffinity.net/user/asofyeun
|
||||
furaffinity: https://www.furaffinity.net/user/AsofYeun
|
||||
inkbunny: https://inkbunny.net/asofyeun
|
||||
sofurry: https://asofyeun.sofurry.com/
|
||||
weasyl: https://www.weasyl.com/~asofyeun
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
name: Bad Manners
|
||||
lang:
|
||||
eng: Bad Manners
|
||||
name:
|
||||
en: Bad Manners
|
||||
tok: nasin ike Pemene
|
||||
avatar: /src/assets/images/logo_bm.png
|
||||
links:
|
||||
|
|
@ -9,8 +8,8 @@ links:
|
|||
furaffinity: https://www.furaffinity.net/user/BadManners
|
||||
inkbunny: https://inkbunny.net/BadManners
|
||||
sofurry:
|
||||
- https://bad-manners.sofurry.com/
|
||||
- Bad Manners
|
||||
link: https://bad-manners.sofurry.com/
|
||||
username: Bad Manners
|
||||
weasyl: https://www.weasyl.com/~BadManners
|
||||
twitter: https://twitter.com/BadManners__
|
||||
mastodon: https://meow.social/@BadManners
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
name: Dr. Hans Woofington
|
||||
links:
|
||||
furaffinity:
|
||||
- https://www.furaffinity.net/user/hanslewdington/
|
||||
- Hans_Lewdington
|
||||
link: https://www.furaffinity.net/user/hanslewdington/
|
||||
username: Hans_Lewdington
|
||||
preferredLink: furaffinity
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ name: YolkMonkey
|
|||
links:
|
||||
furaffinity: https://furaffinity.net/user/Vampire101
|
||||
sofurry:
|
||||
- https://vampire101.sofurry.com/
|
||||
- Vampire101
|
||||
link: https://vampire101.sofurry.com/
|
||||
username: Vampire101
|
||||
preferredLink: furaffinity
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue