Improve feeds according to W3C

This commit is contained in:
Bad Manners 2024-09-15 16:54:28 -03:00
parent d56a8cc95d
commit c1a59ed51a
Signed by: badmanners
GPG key ID: 8C88292CCB075609
9 changed files with 137 additions and 53 deletions

View file

@ -83,7 +83,7 @@ async function exportStory(slug: string, options: { outputDir: string }) {
lines.on("close", reject); lines.on("close", reject);
}); });
console.log(`Astro listening on ${astroURL}`); console.log(`Astro listening on ${astroURL}`);
const response = await fetchRetry(new URL(`/api/healthcheck`, astroURL), { retries: 5, retryDelay: 2000 }); const response = await fetchRetry(new URL(`api/healthcheck`, astroURL), { retries: 5, retryDelay: 2000 });
if (!response.ok) { if (!response.ok) {
throw new Error(response.statusText); throw new Error(response.statusText);
} }
@ -103,7 +103,7 @@ async function exportStory(slug: string, options: { outputDir: string }) {
let storyText = ""; let storyText = "";
try { try {
console.log("Getting data from Astro..."); console.log("Getting data from Astro...");
const response = await fetch(new URL(`/api/export-story/${slug}`, astroURL)); const response = await fetch(new URL(`api/export-story/${slug}`, astroURL));
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to reach export-story API (status code ${response.status})`); throw new Error(`Failed to reach export-story API (status code ${response.status})`);
} }

View file

@ -32,7 +32,7 @@ const { pageTitle, lang = "en", isAgeRestricted } = Astro.props;
rel="alternate" rel="alternate"
type="application/rss+xml" type="application/rss+xml"
title="Gallery | Bad Manners" title="Gallery | Bad Manners"
href={new URL("/feed.xml", Astro.site)} href={new URL("feed.xml", Astro.site)}
/> />
<slot name="head" /> <slot name="head" />
</head> </head>

View file

@ -11,7 +11,6 @@ import {
IconHome, IconHome,
IconMagnifyingGlass, IconMagnifyingGlass,
IconMoon, IconMoon,
IconSquareRSS,
IconSun, IconSun,
IconTags, IconTags,
} from "@components/icons"; } from "@components/icons";

View file

@ -6,6 +6,9 @@ import { blogFeedItem, type EntryWithPubDate } from "@utils/feed";
const MAX_ITEMS = 8; const MAX_ITEMS = 8;
export const GET: APIRoute = async ({ site }) => { export const GET: APIRoute = async ({ site }) => {
if (!site) {
throw new Error("site is required.");
}
const posts = ( const posts = (
(await getCollection("blog", (post) => !post.data.isDraft && post.data.pubDate)) as EntryWithPubDate<"blog">[] (await getCollection("blog", (post) => !post.data.isDraft && post.data.pubDate)) as EntryWithPubDate<"blog">[]
) )
@ -15,7 +18,9 @@ export const GET: APIRoute = async ({ site }) => {
return rss({ return rss({
title: "Blog | Bad Manners", title: "Blog | Bad Manners",
description: "Blog posts by Bad Manners.", description: "Blog posts by Bad Manners.",
site: new URL("/blog", site!), site: new URL("blog", site),
xmlns: { atom: "http://www.w3.org/2005/Atom" },
customData: `<link href="${new URL("blog", site)}" rel="alternate" type="text/html" /><atom:link href="${new URL("blog/feed.xml", site)}" rel="self" type="application/rss+xml" />`,
items: await Promise.all( items: await Promise.all(
posts.map(async ({ data, slug, render }) => blogFeedItem(site, data, slug, (await render()).Content)), posts.map(async ({ data, slug, render }) => blogFeedItem(site, data, slug, (await render()).Content)),
), ),

View file

@ -6,6 +6,9 @@ import { blogFeedItem, gameFeedItem, storyFeedItem, type EntryWithPubDate } from
const MAX_ITEMS = 8; const MAX_ITEMS = 8;
export const GET: APIRoute = async ({ site }) => { export const GET: APIRoute = async ({ site }) => {
if (!site) {
throw new Error("site is required.");
}
const stories = ( const stories = (
(await getCollection( (await getCollection(
"stories", "stories",
@ -28,7 +31,9 @@ export const GET: APIRoute = async ({ site }) => {
return rss({ return rss({
title: "Gallery | Bad Manners", title: "Gallery | Bad Manners",
description: "Stories, games, and more by Bad Manners.", description: "Stories, games, and more by Bad Manners.",
site: site!, site: site,
xmlns: { atom: "http://www.w3.org/2005/Atom" },
customData: `<link href="${site}" rel="alternate" type="text/html" /><atom:link href="${new URL("feed.xml", site)}" rel="self" type="application/rss+xml" />`,
items: await Promise.all( items: await Promise.all(
[ [
stories.map(({ data, slug, render }) => ({ stories.map(({ data, slug, render }) => ({

View file

@ -6,6 +6,9 @@ import { gameFeedItem, type EntryWithPubDate } from "@utils/feed";
const MAX_ITEMS = 8; const MAX_ITEMS = 8;
export const GET: APIRoute = async ({ site }) => { export const GET: APIRoute = async ({ site }) => {
if (!site) {
throw new Error("site is required.");
}
const stories = ( const stories = (
(await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)) as EntryWithPubDate<"games">[] (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)) as EntryWithPubDate<"games">[]
) )
@ -15,7 +18,9 @@ export const GET: APIRoute = async ({ site }) => {
return rss({ return rss({
title: "Games | Bad Manners", title: "Games | Bad Manners",
description: "Games by Bad Manners.", description: "Games by Bad Manners.",
site: new URL("/games", site!), site: new URL("games", site),
xmlns: { atom: "http://www.w3.org/2005/Atom" },
customData: `<link href="${new URL("games", site)}" rel="alternate" type="text/html" /><atom:link href="${new URL("games/feed.xml", site)}" rel="self" type="application/rss+xml" />`,
items: await Promise.all( items: await Promise.all(
stories.map(async ({ data, slug, render }) => gameFeedItem(site, data, slug, (await render()).Content)), stories.map(async ({ data, slug, render }) => gameFeedItem(site, data, slug, (await render()).Content)),
), ),

View file

@ -6,6 +6,9 @@ import { storyFeedItem, type EntryWithPubDate } from "@utils/feed";
const MAX_ITEMS = 8; const MAX_ITEMS = 8;
export const GET: APIRoute = async ({ site }) => { export const GET: APIRoute = async ({ site }) => {
if (!site) {
throw new Error("site is required.");
}
const stories = ( const stories = (
(await getCollection( (await getCollection(
"stories", "stories",
@ -18,7 +21,9 @@ export const GET: APIRoute = async ({ site }) => {
return rss({ return rss({
title: "Stories | Bad Manners", title: "Stories | Bad Manners",
description: "Stories by Bad Manners.", description: "Stories by Bad Manners.",
site: new URL("/stories/1", site!), site: new URL("stories/1", site),
xmlns: { atom: "http://www.w3.org/2005/Atom" },
customData: `<link href="${new URL("stories/1", site)}" rel="alternate" type="text/html" /><atom:link href="${new URL("stories/feed.xml", site)}" rel="self" type="application/rss+xml" />`,
items: await Promise.all( items: await Promise.all(
stories.map(async ({ data, slug, render }) => storyFeedItem(site, data, slug, (await render()).Content)), stories.map(async ({ data, slug, render }) => storyFeedItem(site, data, slug, (await render()).Content)),
), ),

View file

@ -1,5 +1,5 @@
import type { RSSFeedItem } from "@astrojs/rss"; import type { RSSFeedItem } from "@astrojs/rss";
import { getEntries, getEntry, type CollectionEntry, type CollectionKey, type Render } from "astro:content"; import { getEntries, type CollectionEntry, type CollectionKey } from "astro:content";
import { experimental_AstroContainer } from "astro/container"; import { experimental_AstroContainer } from "astro/container";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { getUsernameForLang } from "./get_username_for_lang"; import { getUsernameForLang } from "./get_username_for_lang";
@ -9,6 +9,7 @@ import { markdown } from "@astropub/md";
import { t, type Lang } from "@i18n"; import { t, type Lang } from "@i18n";
import type { AstroComponentFactory } from "astro/runtime/server/index.js"; import type { AstroComponentFactory } from "astro/runtime/server/index.js";
import mdxRenderer from "astro/jsx/server.js"; import mdxRenderer from "astro/jsx/server.js";
import { htmlToAbsoluteUrls } from "./html_to_absolute_urls";
export type FeedItem = RSSFeedItem & export type FeedItem = RSSFeedItem &
Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>; Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>;
@ -33,7 +34,7 @@ const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
}; };
export async function storyFeedItem( export async function storyFeedItem(
site: URL | undefined, site: URL,
data: EntryWithPubDate<"stories">["data"], data: EntryWithPubDate<"stories">["data"],
slug: CollectionEntry<"stories">["slug"], slug: CollectionEntry<"stories">["slug"],
content: AstroComponentFactory, content: AstroComponentFactory,
@ -50,35 +51,41 @@ export async function storyFeedItem(
categories: ["story"], categories: ["story"],
commentsUrl: data.posts.mastodon?.link, commentsUrl: data.posts.mastodon?.link,
content: sanitizeHtml( content: sanitizeHtml(
`<h1>${data.title}</h1>` + htmlToAbsoluteUrls(
`<p>${t( `<h1>${data.title}</h1>` +
data.lang, `<p>${t(
"story/authors", data.lang,
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)), "story/authors",
)}</p>` + (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
(data.requesters )}</p>` +
? `<p>${t( (data.requesters
data.lang, ? `<p>${t(
"story/requested_by", data.lang,
(await getEntries(data.requesters)).map((requester) => getLinkForUser(requester, data.lang)), "story/requested_by",
)}</p>` (await getEntries(data.requesters)).map((requester) => getLinkForUser(requester, data.lang)),
: "") + )}</p>`
(data.commissioners : "") +
? `<p>${t( (data.commissioners
data.lang, ? `<p>${t(
"story/commissioned_by", data.lang,
(await getEntries(data.commissioners)).map((commissioner) => getLinkForUser(commissioner, data.lang)), "story/commissioned_by",
)}</p>` (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><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` +
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`, `<hr>${await container.renderToString(content)}` +
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
site,
),
{
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
},
), ),
}; };
} }
export async function gameFeedItem( export async function gameFeedItem(
site: URL | undefined, site: URL,
data: EntryWithPubDate<"games">["data"], data: EntryWithPubDate<"games">["data"],
slug: CollectionEntry<"games">["slug"], slug: CollectionEntry<"games">["slug"],
content: AstroComponentFactory, content: AstroComponentFactory,
@ -95,22 +102,28 @@ export async function gameFeedItem(
categories: ["game"], categories: ["game"],
commentsUrl: data.posts.mastodon?.link, commentsUrl: data.posts.mastodon?.link,
content: sanitizeHtml( content: sanitizeHtml(
`<h1>${data.title}</h1>` + htmlToAbsoluteUrls(
`<p>${t( `<h1>${data.title}</h1>` +
data.lang, `<p>${t(
"story/authors", data.lang,
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)), "story/authors",
)}</p>` + (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
`<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` + )}</p>` +
`<hr><p><em>${data.contentWarning}</em></p>` + `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
`<hr>${await container.renderToString(content)}` + `<hr><p><em>${data.contentWarning}</em></p>` +
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`, `<hr>${await container.renderToString(content)}` +
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`,
site,
),
{
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
},
), ),
}; };
} }
export async function blogFeedItem( export async function blogFeedItem(
site: URL | undefined, site: URL,
data: EntryWithPubDate<"blog">["data"], data: EntryWithPubDate<"blog">["data"],
slug: CollectionEntry<"blog">["slug"], slug: CollectionEntry<"blog">["slug"],
content: AstroComponentFactory, content: AstroComponentFactory,
@ -126,14 +139,20 @@ export async function blogFeedItem(
categories: ["blog post"], categories: ["blog post"],
commentsUrl: data.posts.mastodon?.link, commentsUrl: data.posts.mastodon?.link,
content: sanitizeHtml( content: sanitizeHtml(
`<h1>${data.title}</h1>` + htmlToAbsoluteUrls(
`<p>${t( `<h1>${data.title}</h1>` +
data.lang, `<p>${t(
"story/authors", data.lang,
(await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)), "story/authors",
)}</p>` + (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)),
`<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}` + )}</p>` +
`<hr>${await container.renderToString(content)}`, `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}` +
`<hr>${await container.renderToString(content)}`,
site!,
),
{
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
},
), ),
}; };
} }

View file

@ -0,0 +1,46 @@
type AnchorMatchGroups = {
prefix: string;
href: string;
suffix: string;
};
type ImgMatchGroups = {
prefix: string;
src: string;
suffix: string;
};
const A_URL_REGEX = /<a\b(?<prefix>[^>]*)\bhref="(?<href>\/[^"]*)"(?<suffix>[^>]*)>/gm;
const IMG_URL_REGEX = /<img\b(?<prefix>[^>]*)\bsrc="(?<src>\/[^"]*)"(?<suffix>[^>]*)>/gm;
/**
* Finds any local URLs in HTML tags (a, img) and replaces them with the appropriate
* fully qualified link.
*/
export function htmlToAbsoluteUrls(originalText: string, site: URL) {
const replacements: { match: string; replace: string }[] = [];
for (const match of originalText.matchAll(A_URL_REGEX)) {
const groups = match.groups as AnchorMatchGroups | undefined;
if (groups?.href === undefined || groups?.prefix === undefined || groups?.suffix === undefined) {
throw new Error(`Cannot make absolute for invalid URL ${match[0]}`);
}
replacements.push({
match: match[0],
replace: `<a${groups.prefix}href="${new URL(groups.href, site)}"${groups.suffix}>`,
});
}
for (const match of originalText.matchAll(IMG_URL_REGEX)) {
const groups = match.groups as ImgMatchGroups | undefined;
if (groups?.src === undefined || groups?.prefix === undefined || groups?.suffix === undefined) {
throw new Error(`Cannot make absolute for invalid URL ${match[0]}`);
}
replacements.push({
match: match[0],
replace: `<img${groups.prefix}src="${new URL(groups.src, site)}"${groups.suffix}>`,
});
}
let newText = originalText;
replacements.forEach(({ match, replace }) => {
newText = newText.replace(match, replace);
});
return newText;
}