Multiple feeds and improve rendering

This commit is contained in:
Bad Manners 2024-09-14 21:55:46 -03:00
parent dadbd32e1b
commit d56a8cc95d
Signed by: badmanners
GPG key ID: 8C88292CCB075609
13 changed files with 273 additions and 184 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "gallery.badmanners.xyz",
"version": "1.8.1",
"version": "1.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gallery.badmanners.xyz",
"version": "1.8.1",
"version": "1.8.2",
"hasInstallScript": true,
"dependencies": {
"@astrojs/check": "^0.9.3",

View file

@ -1,7 +1,7 @@
{
"name": "gallery.badmanners.xyz",
"type": "module",
"version": "1.8.1",
"version": "1.8.2",
"scripts": {
"postinstall": "astro sync",
"dev": "astro dev",

View file

@ -26,15 +26,14 @@ relatedStories:
import NoteTooltip from "@components/NoteTooltip.astro";
export let count = 0;
export function resetCount() {
count = 0;
}
export function getCount() {
count += 1;
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.

View file

@ -84,7 +84,7 @@ const isCurrentRoute = (path: string) =>
<li>
<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" />
<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>
</li>
<li>
@ -104,12 +104,6 @@ const isCurrentRoute = (path: string) =>
<span class="order-3 group-hover:underline group-focus:underline">Search</span>
</a>
</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>
<button
data-dark-mode
@ -144,7 +138,7 @@ const isCurrentRoute = (path: string) =>
</div>
</nav>
<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}
>
<slot />

View 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)),
),
});
};

View file

@ -4,6 +4,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
import GalleryLayout from "@layouts/GalleryLayout.astro";
import UserComponent from "@components/UserComponent.astro";
import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
import { IconSquareRSS } from "@components/icons";
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." />
<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" />
<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">
{
posts.map((post, i) => (

View file

@ -1,139 +1,9 @@
import rss, { type RSSFeedItem } from "@astrojs/rss";
import rss from "@astrojs/rss";
import type { APIRoute } from "astro";
import { getCollection, getEntries, type CollectionEntry, type CollectionKey } from "astro:content";
import { markdown } from "@astropub/md";
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";
import { getCollection } from "astro:content";
import { blogFeedItem, gameFeedItem, storyFeedItem, type EntryWithPubDate } from "@utils/feed";
type FeedItem = RSSFeedItem &
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)}`,
),
};
}
const MAX_ITEMS = 8;
export const GET: APIRoute = async ({ site }) => {
const stories = (
@ -157,21 +27,21 @@ export const GET: APIRoute = async ({ site }) => {
return rss({
title: "Gallery | Bad Manners",
description: "Stories, games, and (possibly) more by Bad Manners",
description: "Stories, games, and more by Bad Manners.",
site: site!,
items: await Promise.all(
[
stories.map(({ data, slug, body }) => ({
stories.map(({ data, slug, render }) => ({
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,
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,
fn: () => blogFeedItem(site, data, slug, body),
fn: async () => blogFeedItem(site, data, slug, (await render()).Content),
})),
]
.flat()

View 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)),
),
});
};

View file

@ -4,6 +4,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
import GalleryLayout from "@layouts/GalleryLayout.astro";
import { t } from "@i18n";
import UserComponent from "@components/UserComponent.astro";
import { IconSquareRSS } from "@components/icons";
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." />
<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" />
<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">
{
games.map((game, i) => (

View file

@ -6,6 +6,7 @@ import GalleryLayout from "@layouts/GalleryLayout.astro";
import { t, type Lang } from "@i18n";
import UserComponent from "@components/UserComponent.astro";
import { markdownToPlaintext } from "@utils/markdown_to_plaintext";
import { IconSquareRSS } from "@components/icons";
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>
<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">
<p class="my-4">
Hey there, welcome to my corner of the Internet! You can expect lots of safe vore/endosoma ahead.
</p>
<ul class="list-disc pl-8">
<li><a class="text-link underline" href="/stories/1">Read my stories!</a></li>
<li><a class="text-link underline" href="/games/crossing-over">Play my visual novel!</a></li>
<li><a class="text-link underline" href="/tags">Find all content with a certain tag!</a></li>
</ul>
<p class="my-4">
For more information about me, please check out <a
class="text-link underline"
href="https://badmanners.xyz/"
data-age-restricted
rel="me">my main website</a
>.
<div class="my-4 flex">
<p class="grow">
Hey there, welcome to my corner of the Internet! This is where I'll share all of the safe vore and endosoma
content that I'll make. You can check the latest uploads below, or use the navigation bar to dig through all of
my content.
</p>
<a class="u-url text-link ml-2 mr-10 p-2" href="/feed.xml" rel="alternate" title="RSS feed" data-tooltip>
<IconSquareRSS width="2rem" height="2rem" />
<span class="sr-only">RSS feed</span>
</a>
</div>
</div>
<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>

View file

@ -5,6 +5,7 @@ import { getCollection, getEntries, type CollectionEntry } from "astro:content";
import GalleryLayout from "@layouts/GalleryLayout.astro";
import { t } from "@i18n";
import UserComponent from "@components/UserComponent.astro";
import { IconSquareRSS } from "@components/icons";
type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } };
@ -30,10 +31,15 @@ const totalPages = Math.ceil(page.total / page.size);
<GalleryLayout pageTitle="Stories" class="h-feed">
<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" />
<div class="p-summary">
<p class="my-4">The bulk of my content!</p>
<div class="my-4 flex">
<p class="p-summary grow">The bulk of my content!</p>
<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" />
<span class="sr-only">RSS feed</span>
</a>
</div>
<p class="text-center font-light text-stone-950 dark:text-white">
{
page.start === page.end
@ -41,7 +47,6 @@ const totalPages = Math.ceil(page.total / page.size);
: `Displaying stories #${page.start + 1}${page.end + 1}`
} / {page.total}
</p>
</div>
<div class="mx-auto mb-6 mt-2 flex w-fit rounded-lg border border-stone-400 dark:border-stone-500">
{
page.url.prev && (

View 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
View 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)}`,
),
};
}