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);
});
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) {
throw new Error(response.statusText);
}
@ -103,7 +103,7 @@ async function exportStory(slug: string, options: { outputDir: string }) {
let storyText = "";
try {
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) {
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"
type="application/rss+xml"
title="Gallery | Bad Manners"
href={new URL("/feed.xml", Astro.site)}
href={new URL("feed.xml", Astro.site)}
/>
<slot name="head" />
</head>

View file

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

View file

@ -6,6 +6,9 @@ import { blogFeedItem, type EntryWithPubDate } from "@utils/feed";
const MAX_ITEMS = 8;
export const GET: APIRoute = async ({ site }) => {
if (!site) {
throw new Error("site is required.");
}
const posts = (
(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({
title: "Blog | 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(
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;
export const GET: APIRoute = async ({ site }) => {
if (!site) {
throw new Error("site is required.");
}
const stories = (
(await getCollection(
"stories",
@ -28,7 +31,9 @@ export const GET: APIRoute = async ({ site }) => {
return rss({
title: "Gallery | 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(
[
stories.map(({ data, slug, render }) => ({

View file

@ -6,6 +6,9 @@ import { gameFeedItem, type EntryWithPubDate } from "@utils/feed";
const MAX_ITEMS = 8;
export const GET: APIRoute = async ({ site }) => {
if (!site) {
throw new Error("site is required.");
}
const stories = (
(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({
title: "Games | 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(
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;
export const GET: APIRoute = async ({ site }) => {
if (!site) {
throw new Error("site is required.");
}
const stories = (
(await getCollection(
"stories",
@ -18,7 +21,9 @@ export const GET: APIRoute = async ({ site }) => {
return rss({
title: "Stories | 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(
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 { 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 sanitizeHtml from "sanitize-html";
import { getUsernameForLang } from "./get_username_for_lang";
@ -9,6 +9,7 @@ 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";
import { htmlToAbsoluteUrls } from "./html_to_absolute_urls";
export type FeedItem = RSSFeedItem &
Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>;
@ -33,7 +34,7 @@ const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
};
export async function storyFeedItem(
site: URL | undefined,
site: URL,
data: EntryWithPubDate<"stories">["data"],
slug: CollectionEntry<"stories">["slug"],
content: AstroComponentFactory,
@ -50,35 +51,41 @@ export async function storyFeedItem(
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))}`,
htmlToAbsoluteUrls(
`<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))}`,
site,
),
{
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
},
),
};
}
export async function gameFeedItem(
site: URL | undefined,
site: URL,
data: EntryWithPubDate<"games">["data"],
slug: CollectionEntry<"games">["slug"],
content: AstroComponentFactory,
@ -95,22 +102,28 @@ export async function gameFeedItem(
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))}`,
htmlToAbsoluteUrls(
`<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))}`,
site,
),
{
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
},
),
};
}
export async function blogFeedItem(
site: URL | undefined,
site: URL,
data: EntryWithPubDate<"blog">["data"],
slug: CollectionEntry<"blog">["slug"],
content: AstroComponentFactory,
@ -126,14 +139,20 @@ export async function blogFeedItem(
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)}`,
htmlToAbsoluteUrls(
`<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)}`,
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;
}