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