Add "Woofer Exploration" and improve drafting

This commit is contained in:
Bad Manners 2024-06-23 18:24:59 -03:00
parent a9d5a88d0e
commit 405ad38f5d
25 changed files with 300 additions and 189 deletions

View file

@ -2,7 +2,7 @@
# slug: some-custom-slug
title: Example Game
# shortTitle: Example
pubDate: 2024-01-01
# pubDate: 2024-01-01
isDraft: true
authors: bad-manners
contentWarning: >
@ -13,10 +13,11 @@ description: |
# descriptionPlaintext: >
# Some funny text.
platforms: [web, windows, linux, macos, android, ios]
# mastodonPost:
# instance: meow.social
# user: BadManners
# postId: "numericalPostId"
# posts:
# mastodon:
# instance: meow.social
# user: BadManners
# postId: "numericalPostId"
tags: []
# series: the-lost-of-the-marshes
# relatedStories: []

View file

@ -2,10 +2,10 @@
# slug: some-custom-slug
title: Example Story
# shortTitle: Example
pubDate: 2024-01-01
# pubDate: 2024-01-01
isDraft: true
authors: bad-manners
wordCount: 1000
# wordCount: 1000
contentWarning: >
Contains: Non-fatal same size oral vore, with willing anthro male fox predator, and unwilling feral female wolf prey. Also includes other stuff.
# thumbnail: /src/assets/thumbnails/story_thumbnail.png
@ -13,10 +13,11 @@ description: |
Some funny text.
# descriptionPlaintext: >
# Some funny text.
# mastodonPost:
# instance: meow.social
# user: BadManners
# postId: "numericalPostId"
# posts:
# mastodon:
# instance: meow.social
# user: BadManners
# postId: "numericalPostId"
tags: []
# series: the-lost-of-the-marshes
# prev: previous-story

Binary file not shown.

After

(image error) Size: 70 KiB

View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -28,9 +28,14 @@ const mastodonPost = z.object({
user: z.string(),
postId: z.string(),
});
const copyrightedCharacters = z
.record(z.string(), reference("users"))
.default({})
.refine(refineCopyrightedCharacters, `"copyrightedCharacters" cannot mix empty catch-all key with other keys`);
export type Lang = z.output<typeof lang>;
export type Website = z.infer<typeof website>;
export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
const storiesCollection = defineCollection({
type: "content",
@ -38,8 +43,8 @@ const storiesCollection = defineCollection({
z.object({
// Required
title: z.string(),
pubDate: z.date().transform(adjustDateForUTCOffset),
wordCount: z.number().int(),
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
wordCount: z.number().int().optional(),
contentWarning: z.string(),
description: z.string(),
tags: z.array(z.string()),
@ -58,16 +63,17 @@ const storiesCollection = defineCollection({
series: reference("series").optional(),
commissioner: reference("users").optional(),
requester: reference("users").optional(),
copyrightedCharacters: z
.record(z.string(), reference("users"))
.default({})
.refine(refineCopyrightedCharacters, `"copyrightedCharacters" cannot mix empty catch-all key with other keys`),
copyrightedCharacters: copyrightedCharacters,
lang,
prev: reference("stories").nullish(),
next: reference("stories").nullish(),
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
mastodonPost: mastodonPost.optional(),
posts: z
.object({
mastodon: mastodonPost.optional(),
})
.default({}),
}),
});
@ -77,7 +83,7 @@ const gamesCollection = defineCollection({
z.object({
// Required
title: z.string(),
pubDate: z.date().transform(adjustDateForUTCOffset),
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
contentWarning: z.string(),
description: z.string(),
tags: z.array(z.string()),
@ -93,14 +99,15 @@ const gamesCollection = defineCollection({
thumbnailHeight: z.number().int().optional(),
series: reference("series").optional(),
platforms: z.array(platform).refine((platforms) => platforms.length > 0, `"platforms" cannot be empty`),
copyrightedCharacters: z
.record(z.string(), reference("users"))
.default({})
.refine(refineCopyrightedCharacters, `"copyrightedCharacters" cannot mix empty catch-all key with other keys`),
copyrightedCharacters: copyrightedCharacters,
lang,
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
mastodonPost: mastodonPost.optional(),
posts: z
.object({
mastodon: mastodonPost.optional(),
})
.default({}),
}),
});

View file

@ -32,10 +32,11 @@ platforms:
- linux
- macos
- android
mastodonPost:
instance: meow.social
user: BadManners
postId: "112009918919441027"
posts:
mastodon:
instance: meow.social
user: BadManners
postId: "112009918919441027"
tags:
- oral vore
- anthro predator

View file

@ -14,10 +14,11 @@ descriptionPlaintext: >
Kolo's day at the airship is nearly over, but a tiny stalker will unwittingly make his evening quite eventful...
Finally got around to finishing a story ever since I worked on Crossing Over! I wanna get back into writing more stuff again, and this short story has finally broken my writer's block. My goal is to go back to working on commissions, but I feel I'm not quite in the headspace to tackle them just yet... Nevertheless, I hope you enjoy this!
mastodonPost:
instance: meow.social
user: BadManners
postId: "112157812554023271"
posts:
mastodon:
instance: meow.social
user: BadManners
postId: "112157812554023271"
tags:
- anthro predator
- anthro prey

View file

@ -0,0 +1,119 @@
---
title: Woofer Exploration
pubDate: 2024-06-23
authors: bad-manners
wordCount: 2600
contentWarning: >
Contains: Non-fatal unbirth and oral vore, with willing anthro maned wolf predator and willing anthro mimic x maned wolf hybrid prey. Also includes gay sex, masturbation, and sleep play.
thumbnail: /src/assets/thumbnails/bm_19_woofer_exploration.png
description: |
The Director wakes up in the middle of the night to a little intruder, and decides to have some fun with him.
This was a gift for my friend Hans! This story is sort of a non-canon sequel to his game ["Director Explorer"](https://hans-woofington.itch.io/director-explorer) on Itch.io, which you should definitely try out if you haven't already!
descriptionPlaintext: >
The Director wakes up in the middle of the night to a little intruder, and decides to have some fun with him.
This was a gift for my friend Hans! This story is sort of a non-canon sequel to his game "Director Explorer" on Itch.io, which you should definitely try out if you haven't already!
tags:
- Sam Brendan
- oral vore
- unbirth
- anthro predator
- anthro prey
- male predator
- male prey
- willing predator
- willing prey
- micro prey
- gay sex
- masturbation
copyrightedCharacters:
"The Director": hans-woofington
"Sam Brendan": bad-manners
---
Late into the night, the Director was finding it hard to remain fully asleep. Though it wasn't from the city lights peering through the window even lying in bed, he still wore his distinctive shades. Plus, under his blanket, the maned wolf was fully naked, which he always preferred when he was alone. No, what prevented him from getting some deep rest was, more than anything, a feeling. A feeling that, unbeknownst to him, there was a commotion going on in his room, even though his large ears couldn't capture the faintest of sounds.
Conceding defeat in his battle against insomnia, he shifted ever so slightly to reach for his lamp and that was when he noticed a subtle touch between his legs that shouldn't have been there. It wasn't from any folds on his fleecy blanket, he was certain, but he wasn't quick to panic either. After flicking the light on, he pushed the blanket aside, exposing his naked form.
Of course, the only one who could see his body in all of its glory was the tiny anthro hanging onto the Director's furry thigh. But instead of admiring the canine, he was instead panicking about getting caught. Before the micro could even react, a large paw scooped him, encasing him in darkness once more as he was pulled away from the large maned wolf's leg. The intruder couldn't help himself but let out a tiny 'eep!', as the rush of air through the gaps of the enormous fingers indicated that he was being brought closer to the rousing woofer's face.
The Director was no stranger to the occasional visit from micros even though he had no idea how, or why, so many of them managed to find their way to him. He could tell that this one was particularly fluffy, from paws to chest and tail to ears, but not in his entirety. There was something hard on the micro apart from the intruder's erection , around the height of his head. Like metal, but warm; giving him a good idea of who had made it into his apartment tonight.
He opened his fist near his snout, and to no surprise, saw a bundle of mostly lime green fur. Despite his annoyance at being woken up so disrespectfully, the maned wolf couldn't help but smile at the familiar micro in his paw.
"Oh, it's him!" The Director's voice was glad, but still very tired. "The Good Manners."
The tiny male pouted, while trying to balance himself on the flimsy floor of the large paw. "Y-You know that's not my name!"
The diminutive intruder was none other than Sam, a hybrid anthro. Being half-maned wolf himself, he shared a lot of physical similarities to the larger canine lots of fur, long legs, fluffy tail , but instead of lighter and darker shades of brown, his coat had much brighter tones of green, white and teal. But one look at his face shattered any further semblances between him and the Director. Sam was also half-mimic, and he had a rectangular metal briefcase face at the front of his head. It could normally part to reveal a large set of jaws, with long teeth and a prehensile tongue but at the moment, its metal lips were only open wide enough to let the micro stick out the tip of his tongue, in a silly blep.
"And I know that this is not your room, either." The regular-sized maned wolf joked, adjusting his round shades. "What are you doing here?"
Sam crossed his arms and leaned against the digit behind him. "W-Well, I-I just wanted to have some fun...with you! I'm sure you were gonna enjoy it, promise! B-But I had to climb up your leg first..."
"But instead of climbing me, you should have used my tiny jetpack, silly! I'm sure I keep it around here somewhere..."
"Jetpack...?" His rectangular face looked around, then he shrugged with his shoulders.
"But most importantly," the Director's tone became more serious, "you should have awaken me first!"
"I-It was gonna be a surprise! I didn't mean to wake you up this late at night."
"Well, you did, and I'm wide awake." He relaxed his back against the mattress while he moved his hand away. "It's time to show me what was this big 'surprise' of yours. And you better make it worth my lost sleep!"
With that, the Director turned his paw, forcefully causing the micro to slip and fall. It was just a few inches, but the dive was higher than Sam's current height. Still, the manhandled hybrid landed harmlessly on the maned wolf's soft and furry underbelly. The mimic landed on his paws, taking a moment to get his bearings. Without any more pretense of being sneaky, he simply turned around to face away from the Director's face, and carefully crawled towards his goal exactly where the Director had wanted him to go.
Sam hastily made his way to the maned wolf's crotch, in order to meet with yet another crucial difference in their anatomies. Whereas the green-furred micro had the usual masculine bits a pair of balls, and a sheath with a lime-hued cock already poking out of , the Director's genitalia was a pussy instead. With no more covers, his labia were fully exposed for the eager micro. Both of them knew they were in for an exciting experience, and neither of them wanted to waste any time.
The tiny hybrid placed his front paws along the sides atop of the vent, his flat face transfixed on the clitoris at that end. Slowly, he approached that fleshy button, its sweet musk and warmth already reaching his face. Then, its metallic surface cracked open, revealing that distinctive mimic maw with long crooked teeth protruding from its gums and a smooth, tentacle-like appendage down the middle. As carefully as his anticipation would allow, he pushed his matte lips against the pink cushion.
"MM...~!" The Director moaned at first contact. He'd been trying his best to not be loud, but he was already too excited to ignore Sam's lewd touch.
Despite his maw's monstrous appearance, the micro was extremely gentle knowing to avoid piercing anything with his teeth while making out with his partner's clitoris. More moans followed the first, each one reverberating the furry floor that he was idly humping. The needy hums urged him to move things along, by way of using more of his long tongue. Like a snake coiling more around its prey, step by step, it contoured and squeezed the protuberance, while Sam's lips continued to excite it from the front.
The larger maned wolf curled his toes and bit his lip, failing to stop the needy sounds coming from his own throat. He bent his knees and slowly spread his legs, his wet vulva flowering from pure neediness. The tiny hybrid was working wonders, but the Director still craved more. Normally, he'd work off his horniness with his fingers or one of his dildos but he currently had his sights on a brand-new toy...
Without warning, Sam was smooshed against the clitoris. It was hard to see with the pressure all over his body, pinning him from head to waist, but he realized that it must have been one of the Director's digits. It started to drag him further down, along the outside of the fleshy vent. He was pressed snugly against the entrance to the vagina, so that there wasn't any risk of slipping and falling. As he approached the source of the aphrodisiac aroma, he instinctively wiggled, though not with the intent of escaping. Then, with the large finger's pulp firmly planted against the back of his head, his face was forced in, parting the lips effortlessly.
"A-Ahh~! Yes...!" The maned wolf gasped. His muscles clenched, keeping the mimic's head firmly in place, letting his folds feel its unique contours. His digit still cupped the fluffy micro in place, meaning the tip was also getting coated by his sexual juices. It then slipped out, then back in, stuffing the green hybrid's shoulders into his tunnel as well.
With his vision completely obscured by pink flesh, Sam's world vibrated with another of the Director's moans. His metal and fur were drenched in fluids, only getting more soaked as he was progressively tucked within the canine. The sweet smell of woofer nectar called to him and if he were to play the role of the Director's toy tonight, he wasn't about to deny its allure. His paws pressed into the wall for balance, sinking into the squishy folds, and his tongue lapped at them, slurping up the earthy lubricant.
Both of them squirmed, not least of all the tall maned wolf. Once he pushed the hybrid's hips inside, he could feel the small canine cock thrusting into his canal. His moans were getting increasingly louder... He was bound to wake up his neighbors at this rate, but he was too aroused to care. And with just Sam's hindpaws and tail sticking out from his vulva, his digit was free to rub himself.
The walls squeezed and contracted around their fluffy and wriggly prey, keeping him trapped fast. The vaginal folds massaged the micro's erection from tip to knot, squeezing pre-cum and gasps out of him. As the Director traced erratic circles from his clitoris to his labia, there was the occasional push to his toy's digitigrade feet. Each nudge forced him further in, until only the white tip of his silky tail was visible. That, too, soon disappeared after its owner, leaving only a ticklish sensation to the walls and the tip of his finger that dove in after the mimic. The Director was picking up his pace as he got closer to his limit.
"H-Hff, hnnnngh fuck~. Give me that good boy seed..."
The command reverberated around Sam, joining all of the wet squelches and the racing heartbeat in drowning out his own gasps and moans. He was fully encased in the Director's vagina; hidden where his presence couldn't have been felt more strongly. Perhaps he couldn't have sneaked in here without awakening the large maned wolf, after all. But regardless of how he ended up inside of him, he couldn't complain about the destination his throbbing erection was proof of it. And as his orgasm became inevitable, he'd finally give them both what they'd been aching for.
The micro's muffled shout, and subsequent jets of seed, should have been negligible due to his size, but the maned wolf's sensitive ears and folds picked up on them easily. With a strained, breath-stealing yell, the Director also came, plugging his vagina with his finger to let his paw get drenched in squirts from his other hole. He felt euphoric and dizzy, all while making a mess of his thighs and his bedsheet.
Resting his head on his pillow, he recovered his breath, feeling both his contractions and the hybrid's wiggles diminish. His toy's cum was his, and it made him feel warm from within. There was some banging at the ceiling from the apartment underneath his it sounded like someone wasn't exactly happy about being rudely awakened this late at night. He blushed a bit, imagining that his uncontrollable moaning had been the culprit. But it was definitely worth it.
Finally, he sat back up, bringing his fingers back into his lower lips. The Director was careful to dig into his folds, which were still hypersensitive from his playtime. It was relatively easy to find Sam's drenched tail, since he hadn't slipped too far into the vagina. The hybrid's ruined fur was slippery, but he managed to fish him out without any trouble.
With a tug, the briefcase-faced anthro slipped out of his cooch, gasping for fresh air as clear strands of sexual juices dribbled from his body. Dangling by his tail in the cold air from the outside world, flaccid green cock still out of its sheath, he was brought face-to-face with the large canine once more.
"Did you make a mess in there?" The gusts of breath alone caused Sam's slimy body to swing in his grasp.
"Yeah... S-Sure did." His aluminum gray face betrayed a slight crimson on his 'cheeks'.
"Good boy~."
"But I thought you were gonna leave me inside for the night!"
"Oh, but I will..."
The Director's maw opened wide before the mimic could even think of a reply. Sam simply blushed harder before he was dropped within the snapping jaws. No sooner had he landed on his tongue, the Director tossed his head back and swallowed. The little wiggling mass was immediately forced into the gaping entrance of the gullet by the slope of his muscle, only providing it with the faintest sensation of his lime pie flavor and the earthiness of the Director's own earthy juices.
A single 'gulp!' sealed his snack's fate and unceremoniously sent him down, nothing more than a slight tickle inside of his throat. To his body, the micro wasn't any different from a piece of food, and Sam made his way to the maned wolf's stomach before either of them could process all of the sensations.
The shade-wearing predator sighed, cleaning the lubrication from his fingers as he rested his other paw over his tummy. There was no bulge from outside, but he could feel his prey writhing inside of his stomach. It was very filling but a bit uncomfortable, and the Director hoped that the hybrid would stop it soon enough. Still, the commotion was enough for him to let out a discreet burp and the gastric breath had a hint of something sugary in it. 'I don't remember eating any candy', he thought to himself absentmindedly...
Trapped in the much more tumultuous wrinkles and flaps of the maned wolf's stomach, Sam had to fight for some space along with a couple of pieces of candy, which had been awaiting him inside. 'Maybe I shouldn't have fed these to him while he was asleep' the thought crossed his mind, though he wasn't too much worried. While the organ would make short work of the sweets, he knew that it would keep him safe in its fleshy confines, even if he stayed overnight. After all, this wasn't his first stay in a stomach; and especially not his first stay in the Director's stomach.
Without much hassle (other than being constricted when a belch erupted from the stomach), he managed to dig some room just for himself, with nothing but slimy, slightly acidic folds embracing his soggy body and rectangular face. With its alluring churns, and the encroaching exhaustion after the adrenaline-inducing fun, Sam finally settled, purring in tandem with his predator's heartbeats. Soon, the Director would also go back to his gentle and soothing snoring but a peaceful slumber would quickly claim the hybrid first, before he could hear them again tonight.
Having both of his appetites sated so wonderfully in one go, the Director slowly thrummed his fingers over his stomach. He tried to imagine what Sam had wanted to do to him before he'd woken up but a timely yawn forced him to push that thought aside for now. He pulled his blanket back over him, covering his furry body and the mess he'd left on his bedsheets. And finally, with his head against his soft pillow, he reached for his bedside lamp and turned it off.

View file

@ -1,4 +0,0 @@
name: destinyisbad1
links:
furaffinity: https://www.furaffinity.net/user/destinyisbad1
preferredLink: furaffinity

View file

@ -1,6 +1,6 @@
name: Hans Woofington
name: Dr. Hans Woofington
links:
furaffinity:
- https://furaffinity.net/user/HansLewdington
- https://www.furaffinity.net/user/hanslewdington/
- Hans_Lewdington
preferredLink: furaffinity

View file

@ -1,4 +0,0 @@
name: Petra
links:
furaffinity: https://www.furaffinity.net/user/PetraThinksUsernamesAreDumb
preferredLink: furaffinity

View file

@ -1,4 +0,0 @@
name: Lee
links:
furaffinity: https://www.furaffinity.net/user/verysmolLee
preferredLink: furaffinity

View file

@ -1,4 +0,0 @@
name: Zilu
links:
furaffinity: https://www.furaffinity.net/user/Zilu
preferredLink: furaffinity

View file

@ -11,6 +11,8 @@ type Props = {
const { pageTitle, enablePagefind } = Astro.props;
const logo = await getImage({ src: logoBM, width: 192 });
const currentYear = new Date().getFullYear();
const copyrightYear = currentYear > 2014 ? `2024${currentYear}` : "2024";
---
<BaseLayout pageTitle={pageTitle}>
@ -38,7 +40,7 @@ const logo = await getImage({ src: logoBM, width: 192 });
<span class="my-2 text-2xl font-semibold">Bad Manners</span>
<Navigation />
<div class="pt-4 text-center text-xs text-black dark:text-white">
<span>&copy; 2024 | </span>
<span>&copy; {copyrightYear} | </span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank">Licenses</a>
</div>
<div class="mt-2 flex items-center gap-x-1 pb-10">

View file

@ -10,27 +10,14 @@ import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
import Prose from "../components/Prose.astro";
import MastodonComments from "../components/MastodonComments.astro";
import UserComponent from "../components/UserComponent.astro";
import { formatCopyrightedCharacters } from "../utils/format_copyrighted_characters";
type Props = CollectionEntry<"games">["data"];
const { props } = Astro;
const series = props.series && (await getEntry(props.series));
const authors = await getEntries([props.authors].flat());
const copyrightedCharacters = await Promise.all(
Object.values(
Object.keys(props.copyrightedCharacters).reduce(
(acc, character) => {
const user = props.copyrightedCharacters[character];
if (!(user.id in acc)) {
acc[user.id] = [getEntry(user), []];
}
acc[user.id][1].push(character);
return acc;
},
{} as Record<string, [Promise<CollectionEntry<"users">>, string[]]>,
),
).map(async ([userPromise, characters]) => [await userPromise, characters] as [CollectionEntry<"users">, string[]]),
);
const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
// const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries(
@ -46,7 +33,7 @@ const categorizedTags = Object.fromEntries(
const tags = props.tags.map<[string, string]>((tag) => {
const tagSlug = slug(tag);
if (!(tag in categorizedTags)) {
console.log(`Tag "${tag}" doesn't have a category in tag-categories!`);
console.log(`Tag "${tag}" doesn't have a category in the "tag-categories" collection!`);
return [tagSlug, tag];
}
return [tagSlug, categorizedTags[tag]!];
@ -187,7 +174,7 @@ const thumbnail =
>
{t(props.lang, "story/draft_warning")}
</p>
) : (
) : props.pubDate ? (
<p
id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
@ -202,7 +189,7 @@ const thumbnail =
>
{t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(undefined, 10))}
</p>
)
) : null
}
<section id="description" class="px-2 font-serif" aria-describedby="title-description">
<h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
@ -222,28 +209,32 @@ const thumbnail =
><span>{t(props.lang, "story/to_top")}</span></a
>
</div>
<section id="tags" aria-describedby="title-tags" class="my-5">
<h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">Tags</h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{
tags.map(([tagSlug, tagText]) => (
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
<a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
{tagText}
</a>
</li>
))
}
</ul>
</section>
{
tags.length > 0 ? (
<section id="tags" aria-describedby="title-tags" class="my-5">
<h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
Tags
</h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{tags.map(([tagSlug, tagText]) => (
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
<a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
{tagText}
</a>
</li>
))}
</ul>
</section>
) : null
}
<MastodonComments
instance={props.mastodonPost?.instance}
user={props.mastodonPost?.user}
postId={props.mastodonPost?.postId}
instance={props.posts.mastodon?.instance}
user={props.posts.mastodon?.user}
postId={props.posts.mastodon?.postId}
/>
</main>
<div class="pt-6 text-center text-xs text-black dark:text-white">
<span>{t(props.lang, "story/copyright_year", props.pubDate.getFullYear())} | </span>
<span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
>{t(props.lang, "story/licenses")}</a
>

View file

@ -12,6 +12,7 @@ import UserComponent from "../components/UserComponent.astro";
import CopyrightedCharacters from "../components/CopyrightedCharacters.astro";
import Prose from "../components/Prose.astro";
import MastodonComments from "../components/MastodonComments.astro";
import { formatCopyrightedCharacters } from "../utils/format_copyrighted_characters";
type Props = CollectionEntry<"stories">["data"];
@ -28,21 +29,7 @@ const series = props.series && (await getEntry(props.series));
const authors = await getEntries([props.authors].flat());
const commissioner = props.commissioner && (await getEntry(props.commissioner));
const requester = props.requester && (await getEntry(props.requester));
const copyrightedCharacters = await Promise.all(
Object.values(
Object.keys(props.copyrightedCharacters).reduce(
(acc, character) => {
const user = props.copyrightedCharacters[character];
if (!(user.id in acc)) {
acc[user.id] = [getEntry(user), []];
}
acc[user.id][1].push(character);
return acc;
},
{} as Record<string, [Promise<CollectionEntry<"users">>, string[]]>,
),
).map(async ([userPromise, characters]) => [await userPromise, characters] as [CollectionEntry<"users">, string[]]),
);
const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries(
@ -58,7 +45,7 @@ const categorizedTags = Object.fromEntries(
const tags = props.tags.map<[string, string]>((tag) => {
const tagSlug = slug(tag);
if (!(tag in categorizedTags)) {
console.log(`Tag "${tag}" doesn't have a category in tag-categories!`);
console.log(`Tag "${tag}" doesn't have a category in the "tag-categories" collection!`);
return [tagSlug, tag];
}
return [tagSlug, categorizedTags[tag]!];
@ -66,15 +53,13 @@ const tags = props.tags.map<[string, string]>((tag) => {
const thumbnail =
props.thumbnail &&
(await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
const wordCount = props.wordCount ? `${props.wordCount}` : "???";
---
<BaseLayout pageTitle={props.title}>
<Fragment slot="head">
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
<meta
property="og:description"
content={t(props.lang, "story/warnings", props.wordCount, props.contentWarning.trim())}
/>
<meta property="og:description" content={t(props.lang, "story/warnings", wordCount, props.contentWarning.trim())} />
<meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" />
{
thumbnail ? (
@ -217,7 +202,7 @@ const thumbnail =
}
<div id="content-warning">
<p>
{t(props.lang, "story/warnings", props.wordCount, props.contentWarning.trim())}
{t(props.lang, "story/warnings", wordCount, props.contentWarning.trim())}
</p>
</div>
</section>
@ -251,7 +236,7 @@ const thumbnail =
>
{t(props.lang, "story/draft_warning")}
</p>
) : (
) : props.pubDate ? (
<p
id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
@ -266,7 +251,7 @@ const thumbnail =
>
{t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(undefined, 10))}
</p>
)
) : null
}
<section id="description" class="px-2 font-serif" aria-describedby="title-description">
<h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
@ -359,30 +344,32 @@ const thumbnail =
</section>
) : null
}
<section id="tags" aria-describedby="title-tags" class="my-5">
<h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
{t(props.lang, "story/tags")}
</h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{
tags.map(([tagSlug, tagText]) => (
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
<a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
{tagText}
</a>
</li>
))
}
</ul>
</section>
{
tags.length > 0 ? (
<section id="tags" aria-describedby="title-tags" class="my-5">
<h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
{t(props.lang, "story/tags")}
</h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{tags.map(([tagSlug, tagText]) => (
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none">
<a class="hover:underline focus:underline" href={`/tags/${tagSlug}`}>
{tagText}
</a>
</li>
))}
</ul>
</section>
) : null
}
<MastodonComments
instance={props.mastodonPost?.instance}
user={props.mastodonPost?.user}
postId={props.mastodonPost?.postId}
instance={props.posts.mastodon?.instance}
user={props.posts.mastodon?.user}
postId={props.posts.mastodon?.postId}
/>
</main>
<div class="pt-6 text-center text-xs text-black dark:text-white">
<span>{t(props.lang, "story/copyright_year", props.pubDate.getFullYear())} | </span>
<span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
>{t(props.lang, "story/licenses")}</a
>

View file

@ -4,18 +4,24 @@ import { marked, type RendererApi } from "marked";
import { decode as tinyDecode } from "tiny-decode";
import type { Lang, Website } from "../../../content/config";
import { t } from "../../../i18n";
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
type ExportFormat = "bbcode" | "markdown";
interface ExportWebsiteInfo {
website: string;
exportFormat: "bbcode" | "markdown";
}
const WEBSITE_LIST = [
["eka", "bbcode"],
["furaffinity", "bbcode"],
["inkbunny", "bbcode"],
["sofurry", "bbcode"],
["weasyl", "markdown"],
] as const satisfies [Website, ExportFormat][];
{ website: "eka", exportFormat: "bbcode" },
{ website: "furaffinity", exportFormat: "bbcode" },
{ website: "inkbunny", exportFormat: "bbcode" },
{ website: "sofurry", exportFormat: "bbcode" },
{ website: "weasyl", exportFormat: "markdown" },
] as const satisfies ExportWebsiteInfo[];
type ExportWebsite = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, ExportFormat]> ? K : never;
type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never;
//type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionExportFormat]> ? K : never;
const bbcodeRenderer: RendererApi = {
strong: (text) => `[b]${text}[/b]`,
@ -122,7 +128,7 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b
return !preferredLink || preferredLink == website;
}
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsite, anonymousFallback: string): string {
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, anonymousFallback: string): string {
if (user.data.isAnonymous) {
return anonymousFallback;
}
@ -207,35 +213,21 @@ export const getStaticPaths: GetStaticPaths = async () => {
export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
const { lang } = story.data;
const copyrightedCharacters = await Promise.all(
Object.values(
Object.keys(story.data.copyrightedCharacters).reduce(
(acc, character) => {
const user = story.data.copyrightedCharacters[character];
if (!(user.id in acc)) {
acc[user.id] = [getEntry(user), []];
}
acc[user.id][1].push(character);
return acc;
},
{} as Record<string, [Promise<CollectionEntry<"users">>, string[]]>,
),
).map(async ([userPromise, characters]) => [await userPromise, characters] as [CollectionEntry<"users">, string[]]),
);
const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters);
const authorsList = await getEntries([story.data.authors].flat());
const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
const requester = story.data.requester && (await getEntry(story.data.requester));
const anonymousUser = await getEntry("users", "anonymous");
const anonymousFallback = getNameForUser(anonymousUser, anonymousUser, lang);
const description: Record<ExportWebsite, string> = Object.fromEntries(
const description: Record<ExportWebsiteName, string> = Object.fromEntries(
await Promise.all(
WEBSITE_LIST.map(async ([website, exportFormat]) => {
WEBSITE_LIST.map(async ({ website, exportFormat }) => {
const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
const storyDescription = (
[
story.data.description,
`*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}*`,
`*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`,
t(
lang,
"export_story/writing",
@ -253,8 +245,7 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
.join("\n\n")
.replaceAll(
/\[([^\]]+)\]\((\/[^\)]+)\)/g,
(_, group1, group2) =>
`[${group1}](${new URL(group2, site).toString()})`,
(_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
);
if (exportFormat === "bbcode") {
return [

View file

@ -29,11 +29,11 @@ const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
};
export const GET: APIRoute = async ({ site }) => {
const stories = (await getCollection("stories", (story) => !story.data.isDraft))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
const stories = (await getCollection("stories", (story) => !story.data.isDraft && story.data.pubDate))
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime())
.slice(0, MAX_ITEMS);
const games = (await getCollection("games", (game) => !game.data.isDraft))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
const games = (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate))
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime())
.slice(0, MAX_ITEMS);
const users = await getCollection("users");
@ -45,7 +45,7 @@ export const GET: APIRoute = async ({ site }) => {
await Promise.all(
stories.map<Promise<FeedItem>>(async ({ data, slug, body }) => ({
title: `New story! "${data.title}"`,
pubDate: toNoonUTCDate(data.pubDate),
pubDate: toNoonUTCDate(data.pubDate!),
link: `/stories/${slug}`,
description:
`${t(data.lang, "story/warnings", data.wordCount, data.contentWarning.trim())} ${data.descriptionPlaintext || data.description}`
@ -81,7 +81,7 @@ export const GET: APIRoute = async ({ site }) => {
await Promise.all(
games.map<Promise<FeedItem>>(async ({ data, slug, body }) => ({
title: `New game! "${data.title}"`,
pubDate: toNoonUTCDate(data.pubDate),
pubDate: toNoonUTCDate(data.pubDate!),
link: `/games/${slug}`,
description:
`${t(data.lang, "game/platforms", data.platforms)}. ${data.contentWarning} ${data.descriptionPlaintext || data.description}`

View file

@ -3,8 +3,8 @@ import { Image } from "astro:assets";
import { getCollection } from "astro:content";
import GalleryLayout from "../layouts/GalleryLayout.astro";
const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
const games = (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)).sort(
(a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime(),
);
---
@ -27,7 +27,7 @@ const games = (await getCollection("games", (game) => !game.data.isDraft)).sort(
<span>{game.data.title}</span>
<br />
<span class="italic">
{game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{game.data.pubDate!.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
</>
</div>

View file

@ -13,11 +13,11 @@ interface LatestItemsEntry {
pubDate: Date;
}
const stories = (await getCollection("stories", (story) => !story.data.isDraft))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
const stories = (await getCollection("stories", (story) => !story.data.isDraft && story.data.pubDate))
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime())
.slice(0, MAX_ITEMS);
const games = (await getCollection("games", (game) => !game.data.isDraft))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
const games = (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate))
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime())
.slice(0, MAX_ITEMS);
const latestItems: LatestItemsEntry[] = [
@ -26,14 +26,14 @@ const latestItems: LatestItemsEntry[] = [
thumbnail: story.data.thumbnail,
href: `/stories/${story.slug}`,
title: story.data.title,
pubDate: story.data.pubDate,
pubDate: story.data.pubDate!,
})),
games.map<LatestItemsEntry>((game) => ({
type: "Game",
thumbnail: game.data.thumbnail,
href: `/games/${game.slug}`,
title: game.data.title,
pubDate: game.data.pubDate,
pubDate: game.data.pubDate!,
})),
]
.flat()

View file

@ -5,7 +5,7 @@ The briefcase logo and any unattributed characters are copyrighted and trademark
The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1.
The generic SVG icons were created by Font Awesome and are distributed under the CC BY 4.0 license.
The generic SVG icons were created by Font Awesome and are distributed under the CC-BY-4.0 license.
All third-party trademarks belong to their respective owners, and I'm not affiliated with any of them.
`.trim();

View file

@ -10,8 +10,8 @@ type Props = {
};
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const stories = (await getCollection("stories", (story) => !story.data.isDraft)).sort(
(a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime(),
const stories = (await getCollection("stories", (story) => !story.data.isDraft && story.data.pubDate)).sort(
(a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime(),
);
return paginate(stories, { pageSize: 30 }) satisfies { props: Props }[];
};
@ -82,7 +82,7 @@ const totalPages = Math.ceil(page.total / page.size);
<span>{story.data.title}</span>
<br />
<span class="italic">
{story.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{story.data.pubDate!.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</span>
</div>
</a>

View file

@ -5,13 +5,16 @@ import GalleryLayout from "../../layouts/GalleryLayout.astro";
import mapImage from "../../assets/images/tlotm_map.jpg";
const series = await getEntry("series", "the-lost-of-the-marshes");
const stories = await getCollection("stories", (story) => !story.data.isDraft && story.data.series?.id === series.id);
const stories = await getCollection(
"stories",
(story) => !story.data.isDraft && story.data.pubDate && story.data.series?.id === series.id,
);
const mainChapters = stories
.filter((story) => story.slug.startsWith("the-lost-of-the-marshes/chapter-"))
.sort((a, b) => a.data.pubDate.getTime() - b.data.pubDate.getTime());
.sort((a, b) => a.data.pubDate!.getTime() - b.data.pubDate!.getTime());
const bonusChapters = stories
.filter((story) => story.slug.startsWith("the-lost-of-the-marshes/bonus-"))
.sort((a, b) => a.data.pubDate.getTime() - b.data.pubDate.getTime());
.sort((a, b) => a.data.pubDate!.getTime() - b.data.pubDate!.getTime());
const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summary);
---
@ -46,9 +49,9 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ
.map((story) => (
<li class="my-2">
<a class="text-link underline" href={`/stories/${story.slug}`}>
{story.data.shortTitle || story.data.title}
{story.data.shortTitle || story.data.title}:
</a>
: <span>{story.data.summary}</span>
<span>{story.data.summary}</span>
</li>
))
}

View file

@ -16,7 +16,8 @@ type Params = {
};
export const getStaticPaths: GetStaticPaths = async () => {
const [stories, games] = await Promise.all([getCollection("stories"), getCollection("games")]);
const [stories, games, series] = await Promise.all([getCollection("stories"), getCollection("games"), getCollection("series")]);
const seriesTags = new Set(series.map((s) => s.data.name));
const tags = new Set<string>();
stories.forEach((story) => {
story.data.tags.forEach((tag) => {
@ -29,17 +30,17 @@ export const getStaticPaths: GetStaticPaths = async () => {
});
});
return [...tags]
.filter((tag) => !["The Lost of the Marshes"].includes(tag))
.filter((tag) => !seriesTags.has(tag))
.map((tag) => ({
params: { slug: slug(tag) } satisfies Params,
props: {
tag,
stories: stories
.filter((story) => !story.data.isDraft && story.data.tags.includes(tag))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()),
.filter((story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag))
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()),
games: games
.filter((game) => !game.data.isDraft && game.data.tags.includes(tag))
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()),
.filter((game) => !game.data.isDraft && game.data.pubDate && game.data.tags.includes(tag))
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()),
} satisfies Props,
}));
};

View file

@ -0,0 +1,20 @@
import { getEntry, type CollectionEntry } from "astro:content";
import type { CopyrightedCharacters } from "../content/config";
export async function formatCopyrightedCharacters(copyrightedCharacters: CopyrightedCharacters) {
return await Promise.all(
Object.values(
Object.keys(copyrightedCharacters).reduce(
(acc, character) => {
const user = copyrightedCharacters[character];
if (!(user.id in acc)) {
acc[user.id] = [getEntry(user), []];
}
acc[user.id][1].push(character);
return acc;
},
{} as Record<string, [Promise<CollectionEntry<"users">>, string[]]>,
),
).map(async ([userPromise, characters]) => [await userPromise, characters] as [CollectionEntry<"users">, string[]]),
);
}