main
1import type { CollectionEntry } from "astro:content";
2import { Feed } from "feed";
3
4import { SITE_CONFIG } from "@/config.ts";
5import { getPostLocaleSlug } from "@/utils/content.ts";
6import { rehypeAbsoluteLinks } from "@/utils/plugins/absolute-links.ts";
7import { rehypeCleanEndnotes } from "@/utils/plugins/clean-endnotes.ts";
8import { rehypeCleanProperties } from "@/utils/plugins/clean-properties.ts";
9import { rehypeExtractBody } from "@/utils/plugins/extract-body.ts";
10import {
11 rehypeInlineImages,
12 rehypeRewriteJsxImages,
13} from "@/utils/plugins/rewrite-images.ts";
14import { renderTypstToHtml } from "@/utils/render-typst.ts";
15import { getRelativeLocaleUrl } from "@/utils/url.ts";
16
17// Matches the `width` that `kp.img` passes to Astro's `<Image />`.
18const FEED_IMAGE_WIDTH = 780;
19
20function renderFeedContent(filePath: string, site: string) {
21 return renderTypstToHtml(filePath, [
22 rehypeCleanEndnotes,
23 rehypeExtractBody,
24 rehypeRewriteJsxImages({ site, width: FEED_IMAGE_WIDTH }),
25 rehypeInlineImages({ site }),
26 rehypeAbsoluteLinks({ site }),
27 rehypeCleanProperties,
28 ]);
29}
30
31export interface FeedData {
32 /** Absolute URL covered by this feed; used as its id and link. */
33 id: string;
34 /** Absolute URL of the feed file itself. */
35 feedUrl: string;
36 /** Absolute URL of the site favicon. */
37 favicon: string;
38 title: string;
39 description: string;
40 language: string;
41 /** Origin that content URLs are resolved against. */
42 site: string;
43 posts: CollectionEntry<"posts">[];
44 /** Absolute URL of a post's page. */
45 postUrl: (post: CollectionEntry<"posts">) => string;
46}
47
48export async function createFeed(data: FeedData): Promise<Feed> {
49 const feed = new Feed({
50 id: data.id,
51 title: data.title,
52 description: data.description,
53 language: data.language,
54 link: data.id,
55 favicon: data.favicon,
56 copyright: `All rights reserved ${new Date().getFullYear()}`,
57 author: { name: SITE_CONFIG.author },
58 feedLinks: { atom: data.feedUrl },
59 });
60
61 const posts = [...data.posts].sort((a, b) =>
62 Temporal.ZonedDateTime.compare(b.data.date, a.data.date),
63 );
64
65 const items = await Promise.all(
66 posts.map(async (post) => {
67 const link = data.postUrl(post);
68 return {
69 title: post.data.title,
70 id: link,
71 link,
72 description: post.data.desc,
73 content:
74 typeof post.filePath === "string"
75 ? await renderFeedContent(post.filePath, data.site)
76 : undefined,
77 author: [{ name: post.data.author || SITE_CONFIG.author }],
78 date: new Date(
79 Temporal.ZonedDateTime.from(post.data.date).epochMilliseconds,
80 ),
81 };
82 }),
83 );
84
85 for (const item of items) {
86 feed.addItem(item);
87 }
88
89 return feed;
90}
91
92/** Absolute URL of a post's page in the locale it was written for. */
93export function localePostUrl(
94 post: CollectionEntry<"posts">,
95 origin: string,
96): string {
97 const { locale, slug } = getPostLocaleSlug(post);
98 return new URL(getRelativeLocaleUrl(slug, locale), origin).toString();
99}