/** * The updates feed. * * Hand-written rather than pulled from a package: the whole of Atom this * needs is one entry per post, the frontmatter already carries every field, * 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 must carry an id, so a reader can tell a retitled post * from a new one. * * Every URL 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 Layout.astro's . */ export const FEED_PATH = "/atom.xml"; // The five characters that are not text in XML. Everything below goes // through here — 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. 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 /posts uses, so a reader and a visitor never disagree // about what has been posted. const posts = ( await getCollection( "posts", ({ 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 reports news on a day nothing was written. With no // posts, the epoch is the honest answer. const updated = posts.length ? changed(posts[0]!) : new Date(0); const entries = posts.map((post) => { const href = url(`/posts/${post.id}`); return [ " ", ` ${xml(post.data.title)}`, ` `, // The address is the id: stable for as long as the post is at it, // which is what trailingSlash: "never" and the canonical link already // promise everywhere else. ` ${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 Layout.astro's autodiscovery link carries. " substandard updates", ` ${xml(url(FEED_PATH))}`, ` ${stamp(updated)}`, // rel="self" is how a reader handed this document knows where to fetch // it again; the alternate is where a human should be sent. ` `, ` `, " substandard", ...entries, "", "", ].join("\n"); return new Response(document, { headers: { "content-type": "application/atom+xml; charset=utf-8" }, }); }