Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
4.7 kB · 112 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113/** * 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 <link>. */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, """) .replace(/'/g, "'");}
export async function GET(context: APIContext): Promise<Response> { 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 [ " <entry>", ` <title>${xml(post.data.title)}</title>`, ` <link rel="alternate" type="text/html" href="${xml(href)}"/>`, // 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. ` <id>${xml(href)}</id>`, ` <published>${stamp(post.data.publishDate)}</published>`, ` <updated>${stamp(changed(post))}</updated>`, ` <summary type="text">${xml(post.data.description)}</summary>`, ...post.data.tags.map((tag) => ` <category term="${xml(tag)}"/>`), " </entry>", ].join("\n"); });
const document = [ '<?xml version="1.0" encoding="utf-8"?>', '<feed xmlns="http://www.w3.org/2005/Atom">', // The same string the blog index's <title> 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</title>", ` <id>${xml(url(FEED_PATH))}</id>`, ` <updated>${stamp(updated)}</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. ` <link rel="self" type="application/atom+xml" href="${xml(url(FEED_PATH))}"/>`, ` <link rel="alternate" type="text/html" href="${xml(url("/blog"))}"/>`, " <author><name>lance.blue</name></author>", ...entries, "</feed>", "", ].join("\n");
return new Response(document, { headers: { "content-type": "application/atom+xml; charset=utf-8" }, });}