Skip to content

Commit baa2c66

Browse files
authored
Add support for the RFC 5005 to RSS (#216)
1 parent ccbe187 commit baa2c66

7 files changed

Lines changed: 511 additions & 69 deletions

File tree

.changeset/marine-lines-post.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'starlight-blog': minor
3+
---
4+
5+
Adds support for the [RFC 5005](https://datatracker.ietf.org/doc/html/rfc5005) to the RSS feed which now exposes monthly archive links to allow compatible feed readers to discover older blog posts.

packages/starlight-blog/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ export default function starlightBlogPlugin(userConfig?: StarlightBlogUserConfig
100100
pattern: '/[...prefix]/rss.xml',
101101
prerender: true,
102102
})
103+
104+
injectRoute({
105+
entrypoint: 'starlight-blog/routes/rss-archive',
106+
pattern: '/[...prefix]/rss/[archive].xml',
107+
prerender: true,
108+
})
103109
}
104110

105111
applyMarkdownPlugin(astroConfig.markdown.processor)

packages/starlight-blog/libs/rss.ts

Lines changed: 216 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RSSOptions } from '@astrojs/rss'
1+
import type { RSSFeedItem, RSSOptions } from '@astrojs/rss'
22
import type { GetStaticPathsResult } from 'astro'
33
import starlightConfig from 'virtual:starlight/user-config'
44
import config from 'virtual:starlight-blog/config'
@@ -9,50 +9,167 @@ import { getBlogEntries, type StarlightBlogEntry } from './content'
99
import { transformHTMLForRSS } from './html'
1010
import { DefaultLocale, getLangFromLocale, type Locale } from './i18n'
1111
import { stripMarkdown } from './markdown'
12-
import { getPathWithLocale, getRelativeUrl } from './page'
12+
import { getPathWithLocale, getRelativeBlogUrl, getRelativeUrl } from './page'
1313
import { getBlogTitle } from './title'
1414

15+
const rssItemLimit = 20
16+
1517
export function getRSSStaticPaths() {
18+
return getRSSLocales().map(getRSSStaticPath) satisfies GetStaticPathsResult
19+
}
20+
21+
export async function getRSSArchiveStaticPaths() {
1622
const paths = []
1723

18-
if (starlightConfig.isMultilingual) {
19-
for (const localeKey of Object.keys(starlightConfig.locales)) {
20-
const locale = localeKey === 'root' ? undefined : localeKey
21-
paths.push(getRSSStaticPath(locale))
24+
for (const locale of getRSSLocales()) {
25+
const entries = await getBlogEntries(locale)
26+
if (entries.length <= rssItemLimit) continue
27+
28+
for (const archive of getRSSArchives(entries)) {
29+
paths.push(getRSSArchiveStaticPath(locale, archive))
2230
}
23-
} else {
24-
paths.push(getRSSStaticPath(DefaultLocale))
2531
}
2632

2733
return paths satisfies GetStaticPathsResult
2834
}
2935

3036
export async function getRSSOptions(site: URL | undefined, locale: Locale, t: App.Locals['t']) {
31-
let entries = await getBlogEntries(locale)
32-
entries = entries.slice(0, 20)
37+
const entries = await getBlogEntries(locale)
38+
39+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The route is only injected if `site` is defined in the user Astro config.
40+
const feedSite = site!
41+
42+
const archives = getRSSArchives(entries)
43+
const rssEntries = archives.length === 0 ? entries : getRSSEntries(entries)
44+
const isComplete = rssEntries.length === entries.length
45+
const links = getRSSLinks(feedSite, locale, archives, isComplete)
46+
47+
return getRSSOptionsForEntries(
48+
rssEntries,
49+
feedSite,
50+
locale,
51+
t,
52+
getRSSCustomData(locale, links, { complete: isComplete }),
53+
)
54+
}
55+
56+
export async function getRSSArchiveOptions(
57+
site: URL | undefined,
58+
locale: Locale,
59+
archive: string | undefined,
60+
t: App.Locals['t'],
61+
) {
62+
if (!archive) {
63+
throw new Error("Missing RSS 'archive' parameter to generate archive RSS feed.")
64+
}
65+
66+
const entries = await getBlogEntries(locale)
67+
const archives = getRSSArchives(entries)
68+
69+
if (!archives.includes(archive)) {
70+
throw new Error(`Unknown RSS archive '${archive}'.`)
71+
}
3372

3473
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- The route is only injected if `site` is defined in the user Astro config.
3574
const feedSite = site!
3675

76+
const archiveEntries = entries.filter((entry) => getRSSArchiveKey(entry.data.date) === archive)
77+
const links = getArchiveRSSLinks(feedSite, locale, archives, archive)
78+
79+
return getRSSOptionsForEntries(
80+
archiveEntries,
81+
feedSite,
82+
locale,
83+
t,
84+
getRSSCustomData(locale, links, { archive: true }),
85+
)
86+
}
87+
88+
function getRSSLocales(): Locale[] {
89+
const locales: Locale[] = []
90+
91+
if (starlightConfig.isMultilingual) {
92+
for (const localeKey of Object.keys(starlightConfig.locales)) {
93+
locales.push(localeKey === 'root' ? undefined : localeKey)
94+
}
95+
} else {
96+
locales.push(DefaultLocale)
97+
}
98+
99+
return locales
100+
}
101+
102+
function getRSSStaticPath(locale: Locale) {
103+
return {
104+
params: {
105+
prefix: getPathWithLocale(config.prefix, locale),
106+
},
107+
}
108+
}
109+
110+
function getRSSArchives(entries: StarlightBlogEntry[]) {
111+
const currentArchive = getCurrentRSSArchiveKey()
112+
const archives = new Set<string>()
113+
114+
for (const entry of entries) {
115+
const archive = getRSSArchiveKey(entry.data.date)
116+
117+
if (archive < currentArchive) {
118+
archives.add(archive)
119+
}
120+
}
121+
122+
return [...archives].toSorted().toReversed()
123+
}
124+
125+
function getRSSArchiveKey(date: Date) {
126+
const year = date.getUTCFullYear()
127+
const month = `${date.getUTCMonth() + 1}`.padStart(2, '0')
128+
129+
return `${year}-${month}`
130+
}
131+
132+
function getCurrentRSSArchiveKey() {
133+
return getRSSArchiveKey(new Date())
134+
}
135+
136+
function getRSSArchiveStaticPath(locale: Locale, archive: string) {
137+
return {
138+
params: {
139+
archive,
140+
prefix: getPathWithLocale(config.prefix, locale),
141+
},
142+
}
143+
}
144+
145+
function getRSSEntries(entries: StarlightBlogEntry[]) {
146+
const currentArchive = getCurrentRSSArchiveKey()
147+
const rssEntries = new Set(entries.slice(0, rssItemLimit))
148+
149+
for (const entry of entries) {
150+
if (getRSSArchiveKey(entry.data.date) >= currentArchive) {
151+
rssEntries.add(entry)
152+
}
153+
}
154+
155+
return [...rssEntries]
156+
}
157+
158+
async function getRSSOptionsForEntries(
159+
entries: StarlightBlogEntry[],
160+
site: URL,
161+
locale: Locale,
162+
t: App.Locals['t'],
163+
customData: string,
164+
) {
37165
const options: RSSOptions = {
38166
title: getRSSTitle(locale),
39167
description: context.description ?? '',
40-
site: feedSite,
41-
items: await Promise.all(
42-
entries.map(async (entry) => {
43-
const link = getRelativeUrl(`/${getPathWithLocale(entry.id, locale)}`)
44-
45-
return {
46-
title: entry.data.title,
47-
link,
48-
pubDate: entry.data.date,
49-
categories: entry.data.tags,
50-
description: getRSSDescription(entry),
51-
content: await getRSSContent(entry, feedSite, t),
52-
}
53-
}),
54-
),
55-
customData: `<language>${getLangFromLocale(locale)}</language>`,
168+
site: site,
169+
// https://datatracker.ietf.org/doc/html/rfc5005#appendix-B
170+
xmlns: { atom: 'http://www.w3.org/2005/Atom', fh: 'http://purl.org/syndication/history/1.0' },
171+
items: await Promise.all(entries.map((entry) => getRSSItem(entry, site, locale, t))),
172+
customData,
56173
}
57174

58175
if (context.trailingSlash !== 'ignore') {
@@ -62,11 +179,21 @@ export async function getRSSOptions(site: URL | undefined, locale: Locale, t: Ap
62179
return options
63180
}
64181

65-
function getRSSStaticPath(locale: Locale) {
182+
async function getRSSItem(
183+
entry: StarlightBlogEntry,
184+
feedSite: URL,
185+
locale: Locale,
186+
t: App.Locals['t'],
187+
): Promise<RSSFeedItem> {
188+
const link = getRelativeUrl(`/${getPathWithLocale(entry.id, locale)}`)
189+
66190
return {
67-
params: {
68-
prefix: getPathWithLocale(config.prefix, locale),
69-
},
191+
title: entry.data.title,
192+
link,
193+
pubDate: entry.data.date,
194+
categories: entry.data.tags,
195+
description: getRSSDescription(entry),
196+
content: await getRSSContent(entry, feedSite, t),
70197
}
71198
}
72199

@@ -104,3 +231,62 @@ async function getRSSContent(entry: StarlightBlogEntry, baseURL: URL, t: App.Loc
104231
const html = await renderBlogEntryToString(entry, t)
105232
return transformHTMLForRSS(html, baseURL)
106233
}
234+
235+
function getRSSCustomData(locale: Locale, links: RSSLink[], options: { archive?: boolean; complete?: boolean } = {}) {
236+
const customData = [`<language>${getLangFromLocale(locale)}</language>`]
237+
238+
if (options.archive) customData.push('<fh:archive/>')
239+
if (options.complete) customData.push('<fh:complete/>')
240+
241+
customData.push(...links.map((link) => `<atom:link rel="${link.rel}" href="${link.href}"/>`))
242+
243+
return customData.join('\n')
244+
}
245+
246+
function getRSSLinks(site: URL, locale: Locale, archives: string[], isComplete: boolean): RSSLink[] {
247+
const links: RSSLink[] = [{ rel: 'self', href: getRSSURL(site, locale) }]
248+
249+
if (!isComplete) {
250+
const previousArchive = archives[0]
251+
252+
if (previousArchive) {
253+
links.push({ rel: 'prev-archive', href: getRSSArchiveURL(site, locale, previousArchive) })
254+
}
255+
}
256+
257+
return links
258+
}
259+
260+
function getArchiveRSSLinks(site: URL, locale: Locale, archives: string[], archive: string): RSSLink[] {
261+
const archiveIndex = archives.indexOf(archive)
262+
const previousArchive = archives[archiveIndex + 1]
263+
const nextArchive = archiveIndex > 0 ? archives[archiveIndex - 1] : undefined
264+
265+
const links: RSSLink[] = [
266+
{ rel: 'current', href: getRSSURL(site, locale) },
267+
{ rel: 'self', href: getRSSArchiveURL(site, locale, archive) },
268+
]
269+
270+
if (previousArchive) {
271+
links.push({ rel: 'prev-archive', href: getRSSArchiveURL(site, locale, previousArchive) })
272+
}
273+
274+
if (nextArchive) {
275+
links.push({ rel: 'next-archive', href: getRSSArchiveURL(site, locale, nextArchive) })
276+
}
277+
278+
return links
279+
}
280+
281+
function getRSSURL(site: URL, locale: Locale) {
282+
return new URL(getRelativeBlogUrl('/rss.xml', locale, true), site).href
283+
}
284+
285+
function getRSSArchiveURL(site: URL, locale: Locale, archive: string) {
286+
return new URL(getRelativeBlogUrl(`/rss/${archive}.xml`, locale, true), site).href
287+
}
288+
289+
interface RSSLink {
290+
href: string
291+
rel: string
292+
}

packages/starlight-blog/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"./routes/Blog.astro": "./routes/Blog.astro",
2020
"./routes/Tags.astro": "./routes/Tags.astro",
2121
"./routes/rss": "./routes/rss.xml.ts",
22+
"./routes/rss-archive": "./routes/rss-archive.xml.ts",
2223
"./schema": "./schema.ts",
2324
"./styles": "./styles.css",
2425
"./package.json": "./package.json"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import rss from '@astrojs/rss'
2+
import type { APIRoute } from 'astro'
3+
4+
import { getLocaleFromPath } from '../libs/page'
5+
import { getRSSArchiveOptions, getRSSArchiveStaticPaths } from '../libs/rss'
6+
7+
export function getStaticPaths() {
8+
return getRSSArchiveStaticPaths()
9+
}
10+
11+
export const GET: APIRoute = async ({ locals, params, site }) => {
12+
return rss(await getRSSArchiveOptions(site, getLocaleFromPath(params['prefix'] ?? ''), params['archive'], locals.t))
13+
}

0 commit comments

Comments
 (0)