/**
* The blog as a feed.
*
* Hand-written rather than pulled from a package: the whole of Atom that this
* needs is one element per post, the frontmatter already carries every field
* it wants, and a feed is a thing you have to be able to read to trust.
*
* Atom rather than RSS because the collection's dates map onto it exactly —
* `published` never moves and `updated` is what `updatedDate` means — and
* because every entry is required to carry an id, so a reader can tell a
* retitled post from a new one.
*
* Every URL here is absolute against `site` in astro.config.mjs. A feed is
* read somewhere else by definition, and a relative href in one resolves
* against the reader, which is nowhere.
*/
import type { APIContext } from "astro";
import { getCollection } from "astro:content";
/** Where readers subscribe. Also spelled out in Base.astro's . */
export const FEED_PATH = "/atom.xml";
/**
* The five characters that are not text in XML.
*
* Everything that goes into the document below goes through here, including
* the parts that look like they could not contain one: a post title is written
* by a human in a markdown file, and an ampersand in one would otherwise end
* the document at that byte for every reader at once.
*/
function xml(text: string): string {
return text
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
export async function GET(context: APIContext): Promise {
const site = context.site;
if (!site) throw new Error("astro.config.mjs has no site; the feed needs it");
const url = (path: string): string => new URL(path, site).href;
// The same rule the blog index uses, so a reader and a visitor never
// disagree about what has been posted. Drafts are readable while writing and
// are not built, and the check is on import.meta.env.DEV rather than on a
// flag so there is no way to ship one by forgetting to turn something back
// on.
const posts = (
await getCollection(
"blog",
({ data }) => import.meta.env.DEV || !data.draft,
)
).sort((a, b) => b.data.publishDate.getTime() - a.data.publishDate.getTime());
const stamp = (date: Date): string => date.toISOString();
const changed = (post: (typeof posts)[number]): Date =>
post.data.updatedDate ?? post.data.publishDate;
// The feed's own timestamp is the newest post's, never the build's. A feed
// stamped at build time changes every deploy, and a reader that trusts the
// field reports news on a day nothing was written.
//
// With no posts at all there is nothing to date it by, and Atom requires the
// element. The epoch is the honest answer: nothing here has ever been
// updated.
const updated = posts.length ? changed(posts[0]!) : new Date(0);
const entries = posts.map((post) => {
const href = url(`/blog/${post.id}`);
return [
" ",
` ${xml(post.data.title)}`,
` `,
// The address is the id. It is stable for as long as the post is at it,
// which is what `trailingSlash: "never"` and the canonical link already
// promise everywhere else on the site.
` ${xml(href)}`,
` ${stamp(post.data.publishDate)}`,
` ${stamp(changed(post))}`,
` ${xml(post.data.description)}`,
...post.data.tags.map((tag) => ` `),
" ",
].join("\n");
});
const document = [
'',
'',
// The same string the blog index's carries, so the two do not
// drift into naming the same thing differently. No : there is
// nothing to say there that the site does not already say.
" Blog · lance.blue",
` ${xml(url(FEED_PATH))}`,
` ${stamp(updated)}`,
// rel="self" is how a reader that was handed this document knows where to
// fetch it again; the alternate is the page a human should be sent to.
` `,
` `,
" lance.blue",
...entries,
"",
"",
].join("\n");
return new Response(document, {
headers: { "content-type": "application/atom+xml; charset=utf-8" },
});
}