/** * 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 <subtitle>: there is // nothing to say there that the site does not already say. " <title>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" }, }); }