eratic / packages / core / src / lib / feeds.ts
659 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
/**
 * Feed generation: RSS 2.0 and JSON Feed 1.1.
 *
 * Both formats are built from one FeedItem[] so a section can never drift
 * between them. Every site with `feeds: true` exposes:
 *
 *   /rss.xml    → RSS 2.0, for every reader ever made
 *   /feed.json  → JSON Feed 1.1, for modern ones
 *
 * On full content
 * ---------------
 * Feeds carry the whole article when the entry is plain Markdown, because
 * Astro's content loader has already rendered it and hands us the HTML. An
 * .mdx entry compiles to JavaScript rather than static HTML — its custom
 * components only exist once a browser-side or build-time render happens — so
 * for those we publish the summary and a link instead of shipping broken
 * markup to a reader's inbox.
 *
 * That is why `summary` is required on every collection schema: it is the text
 * a subscriber sees whenever the full body cannot travel. Astro's Container
 * API could render MDX here, but it is still flagged experimental in v7 and
 * this is the one part of the site that other people's software depends on.
 */

import rss, { type RSSFeedItem } from '@astrojs/rss';
import { IDENTITY, SITES, type SiteKey } from '../config.js';

export interface FeedItem {
	/** Path on this site, e.g. "/2026/a-walk-in-fog/". Leading slash required. */
	path: string;
	title: string;
	/** Plain-text summary. Always present, always the fallback. */
	summary: string;
	pubDate: Date;
	updatedDate?: Date;
	/** Rendered article HTML, if the entry could produce it. */
	content?: string;
	tags?: string[];
	/** Absolute or site-relative URL of a lead image. */
	image?: string;
}

/**
 * Rewrite root-relative URLs to absolute ones.
 *
 * A feed reader has no idea what origin an item came from, so `/img/x.avif`
 * resolves against *its* host and 404s. Anything already absolute, a data URI,
 * a fragment or a mailto: link is left alone.
 */
export function absolutise(html: string, origin: string): string {
	return html.replace(
		/\b(src|href|poster)=("|')(\/(?!\/)[^"']*)\2/gi,
		(_match, attr: string, quote: string, path: string) =>
			`${attr}=${quote}${new URL(path, origin).href}${quote}`,
	);
}

/** Sort newest first, and drop anything dated in the future. */
export function published<T extends { pubDate: Date; draft?: boolean }>(items: T[]): T[] {
	const now = Date.now();
	return items
		.filter((item) => !item.draft && item.pubDate.getTime() <= now)
		.sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime());
}

function itemUrl(site: SiteKey, path: string): string {
	return new URL(path, SITES[site].url).href;
}

/** RSS 2.0 endpoint body. Return this straight from a GET handler. */
export function rssFeed(site: SiteKey, items: FeedItem[]) {
	const def = SITES[site];

	const feedItems: RSSFeedItem[] = items.map((item) => ({
		title: item.title,
		link: itemUrl(site, item.path),
		pubDate: item.pubDate,
		description: item.summary,
		content: item.content ? absolutise(item.content, def.url) : undefined,
		categories: item.tags,
		author: IDENTITY.email ? `${IDENTITY.email} (${IDENTITY.name})` : undefined,
	}));

	return rss({
		title: def.title,
		description: def.description,
		site: def.url,
		items: feedItems,
		trailingSlash: true,
		// Points feed readers at the JSON alternative and declares the language.
		customData: [
			`<language>${IDENTITY.locale}</language>`,
			`<atom:link href="${new URL('/rss.xml', def.url).href}" rel="self" type="application/rss+xml"/>`,
		].join(''),
		xmlns: { atom: 'http://www.w3.org/2005/Atom' },
		// A stylesheet makes the raw XML legible to anyone who clicks the link
		// in a browser rather than pasting it into a reader.
		stylesheet: '/rss.xsl',
	});
}

/** JSON Feed 1.1 endpoint body. Return this straight from a GET handler. */
export function jsonFeed(site: SiteKey, items: FeedItem[]): Response {
	const def = SITES[site];

	const feed = {
		version: 'https://jsonfeed.org/version/1.1',
		title: def.title,
		home_page_url: def.url,
		feed_url: new URL('/feed.json', def.url).href,
		description: def.description,
		language: IDENTITY.locale,
		authors: [{ name: IDENTITY.name, url: SITES.home.url }],
		items: items.map((item) => {
			const url = itemUrl(site, item.path);
			return {
				id: url,
				url,
				title: item.title,
				summary: item.summary,
				content_html: item.content
					? absolutise(item.content, def.url)
					: `<p>${escapeHtml(item.summary)}</p><p><a href="${url}">Read it on the site →</a></p>`,
				date_published: item.pubDate.toISOString(),
				date_modified: item.updatedDate?.toISOString(),
				tags: item.tags?.length ? item.tags : undefined,
				image: item.image ? new URL(item.image, def.url).href : undefined,
			};
		}),
	};

	return new Response(JSON.stringify(feed, null, '\t'), {
		headers: {
			'Content-Type': 'application/feed+json; charset=utf-8',
		},
	});
}

function escapeHtml(text: string): string {
	return text
		.replace(/&/g, '&amp;')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;');
}

static mirror of HEAD · about · clone: git clone https://git.makefuture.ro/eratic.git