Multiple feeds and improve rendering
This commit is contained in:
parent
dadbd32e1b
commit
d56a8cc95d
13 changed files with 273 additions and 184 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "gallery.badmanners.xyz",
|
"name": "gallery.badmanners.xyz",
|
||||||
"version": "1.8.1",
|
"version": "1.8.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gallery.badmanners.xyz",
|
"name": "gallery.badmanners.xyz",
|
||||||
"version": "1.8.1",
|
"version": "1.8.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.3",
|
"@astrojs/check": "^0.9.3",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "gallery.badmanners.xyz",
|
"name": "gallery.badmanners.xyz",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.8.1",
|
"version": "1.8.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "astro sync",
|
"postinstall": "astro sync",
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
|
|
|
@ -26,15 +26,14 @@ relatedStories:
|
||||||
|
|
||||||
import NoteTooltip from "@components/NoteTooltip.astro";
|
import NoteTooltip from "@components/NoteTooltip.astro";
|
||||||
export let count = 0;
|
export let count = 0;
|
||||||
export function resetCount() {
|
|
||||||
count = 0;
|
|
||||||
}
|
|
||||||
export function getCount() {
|
export function getCount() {
|
||||||
count += 1;
|
count += 1;
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
{resetCount()}
|
{function () {
|
||||||
|
count = 0;
|
||||||
|
}()}
|
||||||
|
|
||||||
Going over the story and breaking it down was a fun process, all in all. But this was originally a text document, which I had to completely manually refit into this post you're reading (oof...!). Still, for the sake of posteriority, I think it was worth the effort.
|
Going over the story and breaking it down was a fun process, all in all. But this was originally a text document, which I had to completely manually refit into this post you're reading (oof...!). Still, for the sake of posteriority, I think it was worth the effort.
|
||||||
|
|
||||||
|
|
|
@ -84,7 +84,7 @@ const isCurrentRoute = (path: string) =>
|
||||||
<li>
|
<li>
|
||||||
<a class="u-url text-link group" href="/blog" aria-current={isCurrentRoute("/blog") ? "page" : undefined}>
|
<a class="u-url text-link group" href="/blog" aria-current={isCurrentRoute("/blog") ? "page" : undefined}>
|
||||||
<IconBlog width="1.25rem" height="1.25rem" class="order-1 inline align-text-top" />
|
<IconBlog width="1.25rem" height="1.25rem" class="order-1 inline align-text-top" />
|
||||||
<span class="order-3 group-hover:underline group-focus:underline">Blog posts</span>
|
<span class="order-3 group-hover:underline group-focus:underline">Blog</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
@ -104,12 +104,6 @@ const isCurrentRoute = (path: string) =>
|
||||||
<span class="order-3 group-hover:underline group-focus:underline">Search</span>
|
<span class="order-3 group-hover:underline group-focus:underline">Search</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a class="u-url text-link group" href="/feed.xml">
|
|
||||||
<IconSquareRSS width="1.25rem" height="1.25rem" class="order-1 inline align-text-top" />
|
|
||||||
<span class="order-3 group-hover:underline group-focus:underline">RSS feed</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
data-dark-mode
|
data-dark-mode
|
||||||
|
@ -144,7 +138,7 @@ const isCurrentRoute = (path: string) =>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main
|
<main
|
||||||
class:list={[className, "ml-0 max-w-6xl px-2 pb-28 pt-4 md:px-4 lg:px-8 print:pb-0"]}
|
class:list={[className, "ml-0 w-full max-w-6xl px-2 pb-28 pt-4 md:px-4 lg:px-8 print:pb-0"]}
|
||||||
data-pagefind-body={enablePagefind ? "" : undefined}
|
data-pagefind-body={enablePagefind ? "" : undefined}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|
23
src/pages/blog/feed.xml.ts
Normal file
23
src/pages/blog/feed.xml.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import rss from "@astrojs/rss";
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import { blogFeedItem, type EntryWithPubDate } from "@utils/feed";
|
||||||
|
|
||||||
|
const MAX_ITEMS = 8;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ site }) => {
|
||||||
|
const posts = (
|
||||||
|
(await getCollection("blog", (post) => !post.data.isDraft && post.data.pubDate)) as EntryWithPubDate<"blog">[]
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
|
||||||
|
return rss({
|
||||||
|
title: "Blog | Bad Manners",
|
||||||
|
description: "Blog posts by Bad Manners.",
|
||||||
|
site: new URL("/blog", site!),
|
||||||
|
items: await Promise.all(
|
||||||
|
posts.map(async ({ data, slug, render }) => blogFeedItem(site, data, slug, (await render()).Content)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
|
@ -4,6 +4,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
|
||||||
import GalleryLayout from "@layouts/GalleryLayout.astro";
|
import GalleryLayout from "@layouts/GalleryLayout.astro";
|
||||||
import UserComponent from "@components/UserComponent.astro";
|
import UserComponent from "@components/UserComponent.astro";
|
||||||
import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
|
import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
|
||||||
|
import { IconSquareRSS } from "@components/icons";
|
||||||
|
|
||||||
type PostWithPubDate = CollectionEntry<"blog"> & { data: { pubDate: Date } };
|
type PostWithPubDate = CollectionEntry<"blog"> & { data: { pubDate: Date } };
|
||||||
|
|
||||||
|
@ -21,7 +22,13 @@ const posts = await Promise.all(
|
||||||
<meta slot="head" property="og:description" content="Bad Manners || A game that I've gone and done." />
|
<meta slot="head" property="og:description" content="Bad Manners || A game that I've gone and done." />
|
||||||
<h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Blog</h1>
|
<h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Blog</h1>
|
||||||
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
|
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
|
||||||
<p class="p-summary my-4">Posts on whatever has been rattling in my head as of late.</p>
|
<div class="my-4 flex w-full">
|
||||||
|
<p class="p-summary grow">Posts on whatever has been rattling in my head as of late.</p>
|
||||||
|
<a class="u-url text-link ml-2 mr-10 p-2" href="/blog/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
|
||||||
|
<IconSquareRSS width="2rem" height="2rem" />
|
||||||
|
<span class="sr-only">RSS feed</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
<ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||||
{
|
{
|
||||||
posts.map((post, i) => (
|
posts.map((post, i) => (
|
|
@ -1,139 +1,9 @@
|
||||||
import rss, { type RSSFeedItem } from "@astrojs/rss";
|
import rss from "@astrojs/rss";
|
||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { getCollection, getEntries, type CollectionEntry, type CollectionKey } from "astro:content";
|
import { getCollection } from "astro:content";
|
||||||
import { markdown } from "@astropub/md";
|
import { blogFeedItem, gameFeedItem, storyFeedItem, type EntryWithPubDate } from "@utils/feed";
|
||||||
import sanitizeHtml from "sanitize-html";
|
|
||||||
import { t, type Lang } from "@i18n";
|
|
||||||
import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
|
|
||||||
import { getUsernameForLang } from "@utils/get_username_for_lang";
|
|
||||||
import { qualifyLocalURLsInMarkdown } from "@utils/qualify_local_urls_in_markdown";
|
|
||||||
|
|
||||||
type FeedItem = RSSFeedItem &
|
const MAX_ITEMS = 8;
|
||||||
Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>;
|
|
||||||
|
|
||||||
type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } };
|
|
||||||
|
|
||||||
const MAX_ITEMS = 6;
|
|
||||||
|
|
||||||
function toNoonUTCDate(date: Date) {
|
|
||||||
const adjustedDate = new Date(date);
|
|
||||||
adjustedDate.setUTCHours(12);
|
|
||||||
return adjustedDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
|
|
||||||
const userName = getUsernameForLang(user, lang);
|
|
||||||
if (user.data.preferredLink) {
|
|
||||||
return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`;
|
|
||||||
}
|
|
||||||
return userName;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function storyFeedItem(
|
|
||||||
site: URL | undefined,
|
|
||||||
data: EntryWithPubDate<"stories">["data"],
|
|
||||||
slug: CollectionEntry<"stories">["slug"],
|
|
||||||
body: string,
|
|
||||||
): Promise<FeedItem> {
|
|
||||||
return {
|
|
||||||
title: `New story! "${data.title}"`,
|
|
||||||
pubDate: toNoonUTCDate(data.pubDate),
|
|
||||||
link: `/stories/${slug}`,
|
|
||||||
description:
|
|
||||||
`${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
|
|
||||||
/[\n ]+/g,
|
|
||||||
" ",
|
|
||||||
),
|
|
||||||
categories: ["story"],
|
|
||||||
commentsUrl: data.posts.mastodon?.link,
|
|
||||||
content: sanitizeHtml(
|
|
||||||
`<h1>${data.title}</h1>` +
|
|
||||||
`<p>${t(
|
|
||||||
data.lang,
|
|
||||||
"story/authors",
|
|
||||||
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
|
|
||||||
)}</p>` +
|
|
||||||
(data.requesters
|
|
||||||
? `<p>${t(
|
|
||||||
data.lang,
|
|
||||||
"story/requested_by",
|
|
||||||
(await getEntries(data.requesters)).map((requester) => getLinkForUser(requester, data.lang)),
|
|
||||||
)}</p>`
|
|
||||||
: "") +
|
|
||||||
(data.commissioners
|
|
||||||
? `<p>${t(
|
|
||||||
data.lang,
|
|
||||||
"story/commissioned_by",
|
|
||||||
(await getEntries(data.commissioners)).map((commissioner) => getLinkForUser(commissioner, data.lang)),
|
|
||||||
)}</p>`
|
|
||||||
: "") +
|
|
||||||
`<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` +
|
|
||||||
`<hr>${await markdown(body)}` +
|
|
||||||
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gameFeedItem(
|
|
||||||
site: URL | undefined,
|
|
||||||
data: EntryWithPubDate<"games">["data"],
|
|
||||||
slug: CollectionEntry<"games">["slug"],
|
|
||||||
body: string,
|
|
||||||
): Promise<FeedItem> {
|
|
||||||
return {
|
|
||||||
title: `New game! "${data.title}"`,
|
|
||||||
pubDate: toNoonUTCDate(data.pubDate),
|
|
||||||
link: `/games/${slug}`,
|
|
||||||
description:
|
|
||||||
`${t(data.lang, "game/warnings", data.platforms, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
|
|
||||||
/[\n ]+/g,
|
|
||||||
" ",
|
|
||||||
),
|
|
||||||
categories: ["game"],
|
|
||||||
commentsUrl: data.posts.mastodon?.link,
|
|
||||||
content: sanitizeHtml(
|
|
||||||
`<h1>${data.title}</h1>` +
|
|
||||||
`<p>${t(
|
|
||||||
data.lang,
|
|
||||||
"story/authors",
|
|
||||||
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
|
|
||||||
)}</p>` +
|
|
||||||
`<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
|
|
||||||
`<hr><p><em>${data.contentWarning}</em></p>` +
|
|
||||||
`<hr>${await markdown(body)}` +
|
|
||||||
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function blogFeedItem(
|
|
||||||
site: URL | undefined,
|
|
||||||
data: EntryWithPubDate<"blog">["data"],
|
|
||||||
slug: CollectionEntry<"blog">["slug"],
|
|
||||||
body: string,
|
|
||||||
): Promise<FeedItem> {
|
|
||||||
return {
|
|
||||||
title: `New blog post! "${data.title}"`,
|
|
||||||
pubDate: toNoonUTCDate(data.pubDate),
|
|
||||||
link: `/blog/${slug}`,
|
|
||||||
description: markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site)).replaceAll(
|
|
||||||
/[\n ]+/g,
|
|
||||||
" ",
|
|
||||||
),
|
|
||||||
categories: ["blog post"],
|
|
||||||
commentsUrl: data.posts.mastodon?.link,
|
|
||||||
content: sanitizeHtml(
|
|
||||||
`<h1>${data.title}</h1>` +
|
|
||||||
`<p>${t(
|
|
||||||
data.lang,
|
|
||||||
"story/authors",
|
|
||||||
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
|
|
||||||
)}</p>` +
|
|
||||||
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}` +
|
|
||||||
`<hr>${await markdown(body)}`,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ site }) => {
|
export const GET: APIRoute = async ({ site }) => {
|
||||||
const stories = (
|
const stories = (
|
||||||
|
@ -157,21 +27,21 @@ export const GET: APIRoute = async ({ site }) => {
|
||||||
|
|
||||||
return rss({
|
return rss({
|
||||||
title: "Gallery | Bad Manners",
|
title: "Gallery | Bad Manners",
|
||||||
description: "Stories, games, and (possibly) more by Bad Manners",
|
description: "Stories, games, and more by Bad Manners.",
|
||||||
site: site!,
|
site: site!,
|
||||||
items: await Promise.all(
|
items: await Promise.all(
|
||||||
[
|
[
|
||||||
stories.map(({ data, slug, body }) => ({
|
stories.map(({ data, slug, render }) => ({
|
||||||
date: data.pubDate,
|
date: data.pubDate,
|
||||||
fn: () => storyFeedItem(site, data, slug, body),
|
fn: async () => storyFeedItem(site, data, slug, (await render()).Content),
|
||||||
})),
|
})),
|
||||||
games.map(({ data, slug, body }) => ({
|
games.map(({ data, slug, render }) => ({
|
||||||
date: data.pubDate,
|
date: data.pubDate,
|
||||||
fn: () => gameFeedItem(site, data, slug, body),
|
fn: async () => gameFeedItem(site, data, slug, (await render()).Content),
|
||||||
})),
|
})),
|
||||||
posts.map(({ data, slug, body }) => ({
|
posts.map(({ data, slug, render }) => ({
|
||||||
date: data.pubDate,
|
date: data.pubDate,
|
||||||
fn: () => blogFeedItem(site, data, slug, body),
|
fn: async () => blogFeedItem(site, data, slug, (await render()).Content),
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
.flat()
|
.flat()
|
||||||
|
|
23
src/pages/games/feed.xml.ts
Normal file
23
src/pages/games/feed.xml.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import rss from "@astrojs/rss";
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import { gameFeedItem, type EntryWithPubDate } from "@utils/feed";
|
||||||
|
|
||||||
|
const MAX_ITEMS = 8;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ site }) => {
|
||||||
|
const stories = (
|
||||||
|
(await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)) as EntryWithPubDate<"games">[]
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
|
||||||
|
return rss({
|
||||||
|
title: "Games | Bad Manners",
|
||||||
|
description: "Games by Bad Manners.",
|
||||||
|
site: new URL("/games", site!),
|
||||||
|
items: await Promise.all(
|
||||||
|
stories.map(async ({ data, slug, render }) => gameFeedItem(site, data, slug, (await render()).Content)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
|
@ -4,6 +4,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
|
||||||
import GalleryLayout from "@layouts/GalleryLayout.astro";
|
import GalleryLayout from "@layouts/GalleryLayout.astro";
|
||||||
import { t } from "@i18n";
|
import { t } from "@i18n";
|
||||||
import UserComponent from "@components/UserComponent.astro";
|
import UserComponent from "@components/UserComponent.astro";
|
||||||
|
import { IconSquareRSS } from "@components/icons";
|
||||||
|
|
||||||
type GameWithPubDate = CollectionEntry<"games"> & { data: { pubDate: Date } };
|
type GameWithPubDate = CollectionEntry<"games"> & { data: { pubDate: Date } };
|
||||||
|
|
||||||
|
@ -21,7 +22,13 @@ const games = await Promise.all(
|
||||||
<meta slot="head" property="og:description" content="Bad Manners || A game that I've gone and done." />
|
<meta slot="head" property="og:description" content="Bad Manners || A game that I've gone and done." />
|
||||||
<h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Games</h1>
|
<h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Games</h1>
|
||||||
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
|
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
|
||||||
<p class="p-summary my-4">A game that I've gone and done.</p>
|
<div class="my-4 flex w-full">
|
||||||
|
<p class="p-summary grow">A game that I've gone and done.</p>
|
||||||
|
<a class="u-url text-link ml-2 mr-10 p-2" href="/games/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
|
||||||
|
<IconSquareRSS width="2rem" height="2rem" />
|
||||||
|
<span class="sr-only">RSS feed</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
<ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
|
||||||
{
|
{
|
||||||
games.map((game, i) => (
|
games.map((game, i) => (
|
|
@ -6,6 +6,7 @@ import GalleryLayout from "@layouts/GalleryLayout.astro";
|
||||||
import { t, type Lang } from "@i18n";
|
import { t, type Lang } from "@i18n";
|
||||||
import UserComponent from "@components/UserComponent.astro";
|
import UserComponent from "@components/UserComponent.astro";
|
||||||
import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
|
import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
|
||||||
|
import { IconSquareRSS } from "@components/icons";
|
||||||
|
|
||||||
const MAX_ITEMS = 10;
|
const MAX_ITEMS = 10;
|
||||||
|
|
||||||
|
@ -98,22 +99,17 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
|
||||||
<h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Gallery</h1>
|
<h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Gallery</h1>
|
||||||
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
|
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
|
||||||
<div class="p-summary">
|
<div class="p-summary">
|
||||||
<p class="my-4">
|
<div class="my-4 flex">
|
||||||
Hey there, welcome to my corner of the Internet! You can expect lots of safe vore/endosoma ahead.
|
<p class="grow">
|
||||||
</p>
|
Hey there, welcome to my corner of the Internet! This is where I'll share all of the safe vore and endosoma
|
||||||
<ul class="list-disc pl-8">
|
content that I'll make. You can check the latest uploads below, or use the navigation bar to dig through all of
|
||||||
<li><a class="text-link underline" href="/stories/1">Read my stories!</a></li>
|
my content.
|
||||||
<li><a class="text-link underline" href="/games/crossing-over">Play my visual novel!</a></li>
|
</p>
|
||||||
<li><a class="text-link underline" href="/tags">Find all content with a certain tag!</a></li>
|
<a class="u-url text-link ml-2 mr-10 p-2" href="/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
|
||||||
</ul>
|
<IconSquareRSS width="2rem" height="2rem" />
|
||||||
<p class="my-4">
|
<span class="sr-only">RSS feed</span>
|
||||||
For more information about me, please check out <a
|
</a>
|
||||||
class="text-link underline"
|
</div>
|
||||||
href="https://badmanners.xyz/"
|
|
||||||
data-age-restricted
|
|
||||||
rel="me">my main website</a
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<section class="my-2" aria-labelledby="latest-uploads">
|
<section class="my-2" aria-labelledby="latest-uploads">
|
||||||
<h2 id="latest-uploads" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">Latest uploads</h2>
|
<h2 id="latest-uploads" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100">Latest uploads</h2>
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
|
||||||
import GalleryLayout from "@layouts/GalleryLayout.astro";
|
import GalleryLayout from "@layouts/GalleryLayout.astro";
|
||||||
import { t } from "@i18n";
|
import { t } from "@i18n";
|
||||||
import UserComponent from "@components/UserComponent.astro";
|
import UserComponent from "@components/UserComponent.astro";
|
||||||
|
import { IconSquareRSS } from "@components/icons";
|
||||||
|
|
||||||
type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } };
|
type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } };
|
||||||
|
|
||||||
|
@ -30,18 +31,22 @@ const totalPages = Math.ceil(page.total / page.size);
|
||||||
|
|
||||||
<GalleryLayout pageTitle="Stories" class="h-feed">
|
<GalleryLayout pageTitle="Stories" class="h-feed">
|
||||||
<meta slot="head" property="og:description" content={`Bad Manners || ${page.total} stories.`} />
|
<meta slot="head" property="og:description" content={`Bad Manners || ${page.total} stories.`} />
|
||||||
<h1 class="p-name m-2 text-3xl font-semibold text-stone-800 dark:text-stone-100">Stories</h1>
|
<h1 class="p-name m-2 grow text-3xl font-semibold text-stone-800 dark:text-stone-100">Stories</h1>
|
||||||
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
|
<hr class="mb-3 ml-[2px] mt-2 h-[4px] max-w-xs rounded-sm bg-stone-800 dark:bg-stone-100" />
|
||||||
<div class="p-summary">
|
<div class="my-4 flex">
|
||||||
<p class="my-4">The bulk of my content!</p>
|
<p class="p-summary grow">The bulk of my content!</p>
|
||||||
<p class="text-center font-light text-stone-950 dark:text-white">
|
<a class="u-url text-link ml-2 mr-10 p-2" href="/stories/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
|
||||||
{
|
<IconSquareRSS width="2rem" height="2rem" />
|
||||||
page.start === page.end
|
<span class="sr-only">RSS feed</span>
|
||||||
? `Displaying story #${page.start + 1}`
|
</a>
|
||||||
: `Displaying stories #${page.start + 1}–${page.end + 1}`
|
|
||||||
} / {page.total}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-center font-light text-stone-950 dark:text-white">
|
||||||
|
{
|
||||||
|
page.start === page.end
|
||||||
|
? `Displaying story #${page.start + 1}`
|
||||||
|
: `Displaying stories #${page.start + 1}–${page.end + 1}`
|
||||||
|
} / {page.total}
|
||||||
|
</p>
|
||||||
<div class="mx-auto mb-6 mt-2 flex w-fit rounded-lg border border-stone-400 dark:border-stone-500">
|
<div class="mx-auto mb-6 mt-2 flex w-fit rounded-lg border border-stone-400 dark:border-stone-500">
|
||||||
{
|
{
|
||||||
page.url.prev && (
|
page.url.prev && (
|
||||||
|
|
26
src/pages/stories/feed.xml.ts
Normal file
26
src/pages/stories/feed.xml.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import rss from "@astrojs/rss";
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import { storyFeedItem, type EntryWithPubDate } from "@utils/feed";
|
||||||
|
|
||||||
|
const MAX_ITEMS = 8;
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ site }) => {
|
||||||
|
const stories = (
|
||||||
|
(await getCollection(
|
||||||
|
"stories",
|
||||||
|
(story) => !story.data.isDraft && story.data.pubDate,
|
||||||
|
)) as EntryWithPubDate<"stories">[]
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime())
|
||||||
|
.slice(0, MAX_ITEMS);
|
||||||
|
|
||||||
|
return rss({
|
||||||
|
title: "Stories | Bad Manners",
|
||||||
|
description: "Stories by Bad Manners.",
|
||||||
|
site: new URL("/stories/1", site!),
|
||||||
|
items: await Promise.all(
|
||||||
|
stories.map(async ({ data, slug, render }) => storyFeedItem(site, data, slug, (await render()).Content)),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
139
src/utils/feed.ts
Normal file
139
src/utils/feed.ts
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import type { RSSFeedItem } from "@astrojs/rss";
|
||||||
|
import { getEntries, getEntry, type CollectionEntry, type CollectionKey, type Render } from "astro:content";
|
||||||
|
import { experimental_AstroContainer } from "astro/container";
|
||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
import { getUsernameForLang } from "./get_username_for_lang";
|
||||||
|
import { markdownToPlaintext } from "./markdown_to_plaintext";
|
||||||
|
import { qualifyLocalURLsInMarkdown } from "./qualify_local_urls_in_markdown";
|
||||||
|
import { markdown } from "@astropub/md";
|
||||||
|
import { t, type Lang } from "@i18n";
|
||||||
|
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
||||||
|
import mdxRenderer from "astro/jsx/server.js";
|
||||||
|
|
||||||
|
export type FeedItem = RSSFeedItem &
|
||||||
|
Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>;
|
||||||
|
|
||||||
|
export type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } };
|
||||||
|
|
||||||
|
const container = await experimental_AstroContainer.create();
|
||||||
|
container.addServerRenderer({ renderer: mdxRenderer } as any);
|
||||||
|
|
||||||
|
function toNoonUTCDate(date: Date) {
|
||||||
|
const adjustedDate = new Date(date);
|
||||||
|
adjustedDate.setUTCHours(12);
|
||||||
|
return adjustedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
|
||||||
|
const userName = getUsernameForLang(user, lang);
|
||||||
|
if (user.data.preferredLink) {
|
||||||
|
return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`;
|
||||||
|
}
|
||||||
|
return userName;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function storyFeedItem(
|
||||||
|
site: URL | undefined,
|
||||||
|
data: EntryWithPubDate<"stories">["data"],
|
||||||
|
slug: CollectionEntry<"stories">["slug"],
|
||||||
|
content: AstroComponentFactory,
|
||||||
|
): Promise<FeedItem> {
|
||||||
|
return {
|
||||||
|
title: `New story! "${data.title}"`,
|
||||||
|
pubDate: toNoonUTCDate(data.pubDate),
|
||||||
|
link: `/stories/${slug}`,
|
||||||
|
description:
|
||||||
|
`${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
|
||||||
|
/[\n ]+/g,
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
categories: ["story"],
|
||||||
|
commentsUrl: data.posts.mastodon?.link,
|
||||||
|
content: sanitizeHtml(
|
||||||
|
`<h1>${data.title}</h1>` +
|
||||||
|
`<p>${t(
|
||||||
|
data.lang,
|
||||||
|
"story/authors",
|
||||||
|
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
|
||||||
|
)}</p>` +
|
||||||
|
(data.requesters
|
||||||
|
? `<p>${t(
|
||||||
|
data.lang,
|
||||||
|
"story/requested_by",
|
||||||
|
(await getEntries(data.requesters)).map((requester) => getLinkForUser(requester, data.lang)),
|
||||||
|
)}</p>`
|
||||||
|
: "") +
|
||||||
|
(data.commissioners
|
||||||
|
? `<p>${t(
|
||||||
|
data.lang,
|
||||||
|
"story/commissioned_by",
|
||||||
|
(await getEntries(data.commissioners)).map((commissioner) => getLinkForUser(commissioner, data.lang)),
|
||||||
|
)}</p>`
|
||||||
|
: "") +
|
||||||
|
`<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` +
|
||||||
|
`<hr>${await container.renderToString(content)}` +
|
||||||
|
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gameFeedItem(
|
||||||
|
site: URL | undefined,
|
||||||
|
data: EntryWithPubDate<"games">["data"],
|
||||||
|
slug: CollectionEntry<"games">["slug"],
|
||||||
|
content: AstroComponentFactory,
|
||||||
|
): Promise<FeedItem> {
|
||||||
|
return {
|
||||||
|
title: `New game! "${data.title}"`,
|
||||||
|
pubDate: toNoonUTCDate(data.pubDate),
|
||||||
|
link: `/games/${slug}`,
|
||||||
|
description:
|
||||||
|
`${t(data.lang, "game/warnings", data.platforms, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll(
|
||||||
|
/[\n ]+/g,
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
categories: ["game"],
|
||||||
|
commentsUrl: data.posts.mastodon?.link,
|
||||||
|
content: sanitizeHtml(
|
||||||
|
`<h1>${data.title}</h1>` +
|
||||||
|
`<p>${t(
|
||||||
|
data.lang,
|
||||||
|
"story/authors",
|
||||||
|
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
|
||||||
|
)}</p>` +
|
||||||
|
`<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
|
||||||
|
`<hr><p><em>${data.contentWarning}</em></p>` +
|
||||||
|
`<hr>${await container.renderToString(content)}` +
|
||||||
|
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blogFeedItem(
|
||||||
|
site: URL | undefined,
|
||||||
|
data: EntryWithPubDate<"blog">["data"],
|
||||||
|
slug: CollectionEntry<"blog">["slug"],
|
||||||
|
content: AstroComponentFactory,
|
||||||
|
): Promise<FeedItem> {
|
||||||
|
return {
|
||||||
|
title: `New blog post! "${data.title}"`,
|
||||||
|
pubDate: toNoonUTCDate(data.pubDate),
|
||||||
|
link: `/blog/${slug}`,
|
||||||
|
description: markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site)).replaceAll(
|
||||||
|
/[\n ]+/g,
|
||||||
|
" ",
|
||||||
|
),
|
||||||
|
categories: ["blog post"],
|
||||||
|
commentsUrl: data.posts.mastodon?.link,
|
||||||
|
content: sanitizeHtml(
|
||||||
|
`<h1>${data.title}</h1>` +
|
||||||
|
`<p>${t(
|
||||||
|
data.lang,
|
||||||
|
"story/authors",
|
||||||
|
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
|
||||||
|
)}</p>` +
|
||||||
|
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}` +
|
||||||
|
`<hr>${await container.renderToString(content)}`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue