Improve feeds according to W3C
This commit is contained in:
parent
d56a8cc95d
commit
c1a59ed51a
9 changed files with 137 additions and 53 deletions
|
@ -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})`);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -11,7 +11,6 @@ import {
|
|||
IconHome,
|
||||
IconMagnifyingGlass,
|
||||
IconMoon,
|
||||
IconSquareRSS,
|
||||
IconSun,
|
||||
IconTags,
|
||||
} from "@components/icons";
|
||||
|
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
@ -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 }) => ({
|
||||
|
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
@ -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)),
|
||||
),
|
||||
|
|
|
@ -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"]),
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
46
src/utils/html_to_absolute_urls.ts
Normal file
46
src/utils/html_to_absolute_urls.ts
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue