feeds.mjs
217 lines and 14 functions in DrDustinEdwards/prelum, at commit e389900 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| rssItem | 24 to 47 | 6 | 100% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| <anonymous:42> | 42 to 42 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| rssDocument | 63 to 87 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:64> | 64 to 64 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| atomEntry | 107 to 129 | 7 | 100% | WatchCRAP 7 | No data: mutation testing has not run on this repo yet |
| <anonymous:124> | 124 to 124 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| atomDocument | 144 to 167 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:145> | 145 to 145 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:150> | 150 to 150 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:152> | 152 to 152 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:153> | 153 to 153 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| feedItem | 186 to 203 | 6 | 100% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| jsonFeedDocument | 217 to 228 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:226> | 226 to 226 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
Source
import { absolutiseUrls, cdata, escapeXml, mathToTex, toDate } from "./xml.mjs";
/*
* RSS 2.0, Atom 1.0 and JSON Feed 1.1 from the same post rows. `<description>` keeps the summary because readers show it in the
* LIST view; the post goes in `content:encoded`. URLs are absolutised because a feed item is read on someone else's origin.
* `itemPath` is where a post lives under the origin ("/writing" by default), the one thing the sites disagree on.
*/
const DEFAULT_ITEM_PATH = "/writing";
/**
* @param {{
* slug: string,
* title: string,
* html: string | null,
* description: string | null,
* publishAt: unknown,
* tags: string[],
* author?: string,
* }} post one row of `listBlogPostsRendered`; `author` is RSS's own form, an email address with the name in brackets
* @param {string} origin the site origin, no trailing slash
* @param {string} [itemPath]
*/
export function rssItem(post, origin, itemPath = DEFAULT_ITEM_PATH) {
const url = `${origin}${itemPath}/${post.slug}`;
const published = post.publishAt
? toDate(post.publishAt, null).toUTCString()
: null;
return [
" <item>",
` <title>${escapeXml(post.title)}</title>`,
` <link>${escapeXml(url)}</link>`,
` <guid isPermaLink="true">${escapeXml(url)}</guid>`,
post.description ? ` <description>${escapeXml(post.description)}</description>` : null,
// Omitted rather than empty: an empty `content:encoded` tells a reader the post IS empty.
post.html
? ` <content:encoded>${cdata(absolutiseUrls(mathToTex(post.html), origin))}</content:encoded>`
: null,
published ? ` <pubDate>${published}</pubDate>` : null,
post.author ? ` <author>${escapeXml(post.author)}</author>` : null,
...post.tags.map((tag) => ` <category>${escapeXml(tag)}</category>`),
" </item>",
]
.filter(Boolean)
.join("\n");
}
/**
* @param {{
* title: string,
* link: string,
* description: string,
* selfUrl: string,
* posts: Array<Parameters<typeof rssItem>[0]>,
* origin: string,
* itemPath?: string,
* lastBuildDate?: unknown,
* image?: { url: string, title: string, link: string },
* }} feed `lastBuildDate` is when the content last changed (the newest post), never the clock, so a render stays cacheable
* @returns {string}
*/
export function rssDocument(feed) {
const items = feed.posts.map((post) => rssItem(post, feed.origin, feed.itemPath)).join("\n");
const lastBuildDate = feed.lastBuildDate
? `\n <lastBuildDate>${toDate(feed.lastBuildDate, null).toUTCString()}</lastBuildDate>`
: "";
const image = feed.image
? `\n <image>
<url>${escapeXml(feed.image.url)}</url>
<title>${escapeXml(feed.image.title)}</title>
<link>${escapeXml(feed.image.link)}</link>
</image>`
: "";
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${escapeXml(feed.title)}</title>
<link>${escapeXml(feed.link)}</link>
<description>${escapeXml(feed.description)}</description>
<language>en-us</language>${lastBuildDate}
<atom:link href="${escapeXml(feed.selfUrl)}" rel="self" type="application/rss+xml" />${image}
${items}
</channel>
</rss>
`;
}
/**
* The body is CDATA rather than escaped inline: double-escaping is how a feed
* ends up showing tags as text. No rendered HTML means no `<content>` at all,
* since an empty one asserts the post is empty.
*
* @param {{
* slug: string,
* title: string,
* html: string | null,
* description: string | null,
* publishAt: unknown,
* updatedAt: unknown,
* tags: string[],
* }} post
* @param {string} origin
* @param {string} [itemPath]
* @returns {string}
*/
export function atomEntry(post, origin, itemPath = DEFAULT_ITEM_PATH) {
const url = `${origin}${itemPath}/${post.slug}`;
const published = post.publishAt ? toDate(post.publishAt, null) : null;
const updated = post.updatedAt ? toDate(post.updatedAt, null) : published;
return [
" <entry>",
` <title>${escapeXml(post.title)}</title>`,
` <link href="${escapeXml(url)}" rel="alternate" type="text/html" />`,
` <id>${escapeXml(url)}</id>`,
// An entry with no `updated` is invalid Atom, hence the epoch fallback.
` <updated>${(updated ?? new Date(0)).toISOString()}</updated>`,
published ? ` <published>${published.toISOString()}</published>` : null,
post.description ? ` <summary>${escapeXml(post.description)}</summary>` : null,
post.html
? ` <content type="html">${cdata(absolutiseUrls(mathToTex(post.html), origin))}</content>`
: null,
...post.tags.map((tag) => ` <category term="${escapeXml(tag)}" />`),
" </entry>",
]
.filter(Boolean)
.join("\n");
}
/**
* @param {{
* title: string,
* subtitle: string,
* alternateUrl: string,
* selfUrl: string,
* authorName: string,
* posts: Array<Parameters<typeof atomEntry>[0]>,
* origin: string,
* itemPath?: string,
* }} feed
* @returns {string}
*/
export function atomDocument(feed) {
const entries = feed.posts.map((post) => atomEntry(post, feed.origin, feed.itemPath)).join("\n");
// Derived from the rows rather than a clock, so two renders of the same corpus
// are byte-identical and the edge can cache them. An empty feed gets the epoch,
// which validates where an empty `<updated>` does not.
const newest = feed.posts
.map((post) => post.updatedAt ?? post.publishAt)
.filter(Boolean)
.map((value) => toDate(value, null).getTime())
.reduce((a, b) => Math.max(a, b), 0);
return `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${escapeXml(feed.title)}</title>
<subtitle>${escapeXml(feed.subtitle)}</subtitle>
<link href="${escapeXml(feed.alternateUrl)}" rel="alternate" type="text/html" />
<link href="${escapeXml(feed.selfUrl)}" rel="self" type="application/atom+xml" />
<id>${escapeXml(feed.alternateUrl)}</id>
<updated>${new Date(newest).toISOString()}</updated>
<author><name>${escapeXml(feed.authorName)}</name><uri>${escapeXml(feed.origin)}</uri></author>
${entries}
</feed>
`;
}
/**
* Undefined-valued keys are how an optional field is omitted (JSON.stringify
* drops them), so a direct consumer must judge presence by value, not by `in`.
*
* @param {{
* slug: string,
* title: string,
* body: string,
* description: string | null,
* publishAt: unknown,
* updatedAt: unknown,
* coverImage: string | null,
* tags: string[],
* }} post one row of `listBlogPostsFullText`
* @param {string} origin the site origin, no trailing slash
* @param {string} [itemPath]
*/
export function feedItem(post, origin, itemPath = DEFAULT_ITEM_PATH) {
return {
id: `${origin}${itemPath}/${post.slug}`,
url: `${origin}${itemPath}/${post.slug}`,
title: post.title,
// JSON Feed 1.1 requires content_html or content_text on every item.
content_text: post.body,
summary: post.description ?? undefined,
date_published: post.publishAt
? toDate(post.publishAt, null).toISOString()
: undefined,
date_modified: post.updatedAt
? toDate(post.updatedAt, null).toISOString()
: undefined,
tags: post.tags.length > 0 ? post.tags : undefined,
image: post.coverImage ? `${origin}${post.coverImage}` : undefined,
};
}
/**
* @param {{
* title: string,
* homePageUrl: string,
* feedUrl: string,
* description: string,
* authorName: string,
* posts: Array<Parameters<typeof feedItem>[0]>,
* origin: string,
* itemPath?: string,
* }} feed
*/
export function jsonFeedDocument(feed) {
return {
version: "https://jsonfeed.org/version/1.1",
title: feed.title,
home_page_url: feed.homePageUrl,
feed_url: feed.feedUrl,
description: feed.description,
language: "en-US",
authors: [{ name: feed.authorName, url: feed.origin }],
items: feed.posts.map((post) => feedItem(post, feed.origin, feed.itemPath)),
};
}