1- import type { RSSOptions } from '@astrojs/rss'
1+ import type { RSSFeedItem , RSSOptions } from '@astrojs/rss'
22import type { GetStaticPathsResult } from 'astro'
33import starlightConfig from 'virtual:starlight/user-config'
44import config from 'virtual:starlight-blog/config'
@@ -9,50 +9,167 @@ import { getBlogEntries, type StarlightBlogEntry } from './content'
99import { transformHTMLForRSS } from './html'
1010import { DefaultLocale , getLangFromLocale , type Locale } from './i18n'
1111import { stripMarkdown } from './markdown'
12- import { getPathWithLocale , getRelativeUrl } from './page'
12+ import { getPathWithLocale , getRelativeBlogUrl , getRelativeUrl } from './page'
1313import { getBlogTitle } from './title'
1414
15+ const rssItemLimit = 20
16+
1517export 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
3036export 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+ }
0 commit comments