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}