/**
* 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" },
});
}