11import LZString from "lz-string" ;
2- import { encodeContent } from "@/lib/encoding" ;
32import { type NextRequest , NextResponse } from "next/server" ;
4- import Parser from "rss-parser" ;
5- import { CustomFeed , CustomItem , JSONFeed } from "@/lib/types" ;
6-
7- const parser = new Parser ( {
8- customFields : {
9- item : [
10- [ "content:encoded" , "content" ] ,
11- [ "dc:creator" , "creator" ] ,
12- ] ,
13- } ,
14- } ) ;
3+ import {
4+ parseFeedFromUrl ,
5+ mergeFeeds ,
6+ generateRSS ,
7+ generateJSONFeed ,
8+ } from "@/lib/rss" ;
159
1610const GENERATOR = "rssrssrssrss" ;
17- const FEED_TITLE = "Merged Feed" ;
18-
19- // Helper functions for JSON Feed detection and parsing
20- async function isJSONFeed ( url : string ) : Promise < boolean > {
21- try {
22- const response = await fetch ( url , {
23- headers : { Accept : "application/json, application/feed+json, */*" } ,
24- } ) ;
25- const contentType = response . headers . get ( "content-type" ) || "" ;
26-
27- if (
28- contentType . includes ( "application/feed+json" ) ||
29- contentType . includes ( "application/json" )
30- ) {
31- const text = await response . text ( ) ;
32- const data = JSON . parse ( text ) ;
33- return data . version && data . version . includes ( "jsonfeed.org" ) ;
34- }
35-
36- return false ;
37- } catch {
38- return false ;
39- }
40- }
41-
42- async function parseJSONFeed ( url : string ) : Promise < CustomFeed > {
43- const response = await fetch ( url , {
44- headers : { Accept : "application/json, application/feed+json, */*" } ,
45- } ) ;
46- const jsonFeed : JSONFeed = await response . json ( ) ;
47-
48- // Convert JSON Feed items to CustomItem format
49- const items : CustomItem [ ] = jsonFeed . items . map ( ( item ) => ( {
50- title : item . title ,
51- link : item . url || item . external_url ,
52- pubDate : item . date_published ,
53- content : item . content_html ,
54- contentSnippet : item . content_text || item . summary ,
55- creator : item . author ?. name ,
56- isoDate : item . date_published ,
57- guid : item . id ,
58- categories : item . tags ,
59- sourceFeedTitle : jsonFeed . title ,
60- sourceFeedUrl : url ,
61- } ) ) ;
62-
63- return {
64- title : jsonFeed . title ,
65- description : jsonFeed . description ,
66- link : jsonFeed . home_page_url ,
67- items,
68- } ;
69- }
70-
71- // Helper functions for XML generation
72- function escapeXml ( unsafe : string ) : string {
73- return unsafe
74- . replace ( / & / g, "&" )
75- . replace ( / < / g, "<" )
76- . replace ( / > / g, ">" )
77- . replace ( / " / g, """ )
78- . replace ( / ' / g, "'" ) ;
79- }
80-
81- function wrapCDATA ( content : string ) : string {
82- return `<![CDATA[${ content } ]]>` ;
83- }
84-
85- // Helper function to generate JSON Feed output
86- function generateJSONFeed ( mergedFeed : CustomFeed , requestUrl : string ) : string {
87- const jsonFeed : JSONFeed = {
88- version : "https://jsonfeed.org/version/1.1" ,
89- title : mergedFeed . title || FEED_TITLE ,
90- description : mergedFeed . description ,
91- home_page_url : mergedFeed . link ,
92- feed_url : requestUrl ,
93- items : mergedFeed . items . map ( ( item ) => ( {
94- id : item . guid || item . link || crypto . randomUUID ( ) ,
95- url : item . link ,
96- title : item . title ,
97- content_html : item . content ,
98- content_text : item . contentSnippet ,
99- date_published : item . isoDate || item . pubDate ,
100- author : item . creator ? { name : item . creator } : undefined ,
101- tags : item . categories ,
102- } ) ) ,
103- } ;
104-
105- return JSON . stringify ( jsonFeed , null , 2 ) ;
106- }
10711
10812const HEADERS = {
10913 "Content-Type" : "application/rss+xml; charset=utf-8" ,
@@ -141,8 +45,7 @@ export async function GET(request: NextRequest) {
14145 }
14246 return NextResponse . json (
14347 {
144- error :
145- `${ GENERATOR } cannot parse that payload. Are you sure you copied/pasted it correctly?` ,
48+ error : `${ GENERATOR } cannot parse that payload. Are you sure you copied/pasted it correctly?` ,
14649 payload : compressedFeeds ,
14750 } ,
14851 { status : 400 }
@@ -163,86 +66,14 @@ export async function GET(request: NextRequest) {
16366
16467 // Fetch and parse all feeds (RSS and JSON) in parallel
16568 const feedPromises = urls . map ( async ( url ) => {
166- try {
167- // Check if it's a JSON Feed first
168- if ( await isJSONFeed ( url ) ) {
169- return { feed : await parseJSONFeed ( url ) , error : null , url } ;
170- } else {
171- // Fall back to RSS parsing
172- const feed = await parser . parseURL ( url ) ;
173- return {
174- feed : {
175- ...feed ,
176- items : feed . items . map ( ( item : CustomItem ) => ( {
177- ...item ,
178- sourceFeedTitle : feed . title ,
179- sourceFeedUrl : url ,
180- } ) ) ,
181- } ,
182- error : null ,
183- url,
184- } ;
185- }
186- } catch ( error ) {
187- console . error ( `Error fetching feed from ${ url } :` , error ) ;
188- return {
189- feed : null ,
190- error : error instanceof Error ? error . message : String ( error ) ,
191- url,
192- } ;
193- }
69+ const result = await parseFeedFromUrl ( url ) ;
70+ return { ...result , url } ;
19471 } ) ;
19572
19673 const results = await Promise . all ( feedPromises ) ;
19774
198- // Combine all items into a single array, and collect failed feeds
199- const allItems : CustomItem [ ] = [ ] ;
200- const failedFeeds : Array < { url : string ; error : string } > = [ ] ;
201-
202- results . forEach ( ( { feed, error, url } ) => {
203- if ( error ) {
204- failedFeeds . push ( { url, error } ) ;
205- } else if ( feed && feed . items && feed . items . length > 0 ) {
206- allItems . push ( ...feed . items ) ;
207- }
208- } ) ;
209-
210- // Create error items for failed feeds and add them to the beginning
211- const errorItems : CustomItem [ ] = failedFeeds . map ( ( failed ) => ( {
212- title : `⚠️ Failed to load feed: ${ failed . url } ` ,
213- link : failed . url ,
214- pubDate : new Date ( ) . toUTCString ( ) ,
215- isoDate : new Date ( ) . toISOString ( ) ,
216- contentSnippet : `Error: ${ failed . error } ` ,
217- content : `<p>Failed to load this feed:</p><p><code>${ escapeXml ( failed . url ) } </code></p><p>Error: ${ escapeXml ( failed . error ) } </p>` ,
218- guid : `error-${ failed . url } -${ Date . now ( ) } ` ,
219- } ) ) ;
220-
221- // Sort regular items by date (newest first), keep error items at top
222- allItems . sort ( ( a , b ) => {
223- const dateA = a . isoDate ? new Date ( a . isoDate ) : new Date ( a . pubDate || 0 ) ;
224- const dateB = b . isoDate ? new Date ( b . isoDate ) : new Date ( b . pubDate || 0 ) ;
225- return dateB . getTime ( ) - dateA . getTime ( ) ;
226- } ) ;
227-
228- // Combine error items (at the top) with sorted regular items
229- const allItemsWithErrors : CustomItem [ ] = [ ...errorItems , ...allItems ] ;
230-
231- // Get feed titles from successful feeds for the description
232- const successfulFeedTitles = results
233- . filter ( ( { feed } ) => feed && feed . title )
234- . map ( ( { feed } ) => feed ?. title )
235- . filter ( Boolean ) as string [ ] ;
236-
237- // Create a merged feed
238- const mergedFeed : CustomFeed = {
239- title : FEED_TITLE ,
240- description : `Combined feed from ${ successfulFeedTitles . join ( ", " ) } ${
241- failedFeeds . length > 0 ? ` (${ failedFeeds . length } feed(s) failed to load)` : ""
242- } `,
243- link : request . nextUrl . toString ( ) ,
244- items : allItemsWithErrors . slice ( 0 , 100 ) ,
245- } ;
75+ // Merge all feeds into a single feed
76+ const mergedFeed = mergeFeeds ( results , request . nextUrl . toString ( ) ) ;
24677
24778 // Check if JSON format is requested
24879 if ( format === "json" || format === "jsonfeed" ) {
@@ -257,85 +88,7 @@ export async function GET(request: NextRequest) {
25788 }
25889
25990 // Generate XML using string concatenation (default RSS output)
260- const items = mergedFeed . items
261- . map ( ( item ) => {
262- let itemXml = " <item>\n" ;
263-
264- // Title
265- if ( item . title ) {
266- itemXml += ` <title>${ escapeXml ( item . title ) } </title>\n` ;
267- } else {
268- itemXml += ` <title />\n` ;
269- }
270-
271- // Link
272- if ( item . link ) {
273- itemXml += ` <link>${ escapeXml ( item . link ) } </link>\n` ;
274- }
275-
276- // GUID
277- itemXml += ` <guid>${ escapeXml (
278- item . guid || item . link || ""
279- ) } </guid>\n`;
280-
281- // Publication date
282- if ( item . pubDate ) {
283- itemXml += ` <pubDate>${ escapeXml ( item . pubDate ) } </pubDate>\n` ;
284- } else if ( item . isoDate ) {
285- itemXml += ` <pubDate>${ escapeXml ( item . isoDate ) } </pubDate>\n` ;
286- }
287-
288- // Creator (DC namespace)
289- if ( item . creator ) {
290- itemXml += ` <dc:creator>${ wrapCDATA (
291- item . creator
292- ) } </dc:creator>\n`;
293- }
294-
295- // Content or description
296- if ( item . content ) {
297- // Note that we don't need to encode this because we're wrapping it in CData.
298- // Per #11, encoding it just removes smart quotes and things of that nature unnecessarily.
299- itemXml += ` <content:encoded>${ wrapCDATA (
300- item . content
301- ) } </content:encoded>\n`;
302- } else if ( item . contentSnippet ) {
303- itemXml += ` <description>${ escapeXml (
304- encodeContent ( item . contentSnippet )
305- ) } </description>\n`;
306- }
307-
308- // Categories
309- if ( item . categories && item . categories . length > 0 ) {
310- item . categories . forEach ( ( category ) => {
311- itemXml += ` <category>${ escapeXml ( category ) } </category>\n` ;
312- } ) ;
313- }
314-
315- // Source information
316- if ( item . sourceFeedTitle && item . sourceFeedUrl ) {
317- itemXml += ` <source url="${ escapeXml (
318- item . sourceFeedUrl
319- ) } ">${ escapeXml ( item . sourceFeedTitle ) } </source>\n`;
320- }
321-
322- itemXml += " </item>\n" ;
323- return itemXml ;
324- } )
325- . join ( "" ) ;
326-
327- const xml = `<?xml version="1.0" encoding="UTF-8"?>
328- <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
329- <channel>
330- <title>${ escapeXml ( mergedFeed . title || FEED_TITLE ) } </title>
331- <description>${ escapeXml (
332- mergedFeed . description || "Combined feed from multiple sources"
333- ) } </description>
334- <link>${ escapeXml ( mergedFeed . link || request . nextUrl . toString ( ) ) } </link>
335- <lastBuildDate>${ new Date ( ) . toUTCString ( ) } </lastBuildDate>
336- <generator>${ GENERATOR } </generator>
337- ${ items } </channel>
338- </rss>` ;
91+ const xml = generateRSS ( mergedFeed , request . nextUrl . toString ( ) ) ;
33992
34093 // Return the XML response
34194 return new NextResponse ( xml , {
0 commit comments