Skip to content

Commit 31a43ce

Browse files
authored
Refactor merge/route.ts; fix category issue (#18)
* WIP * WIP * WIP
1 parent ec4ba7b commit 31a43ce

6 files changed

Lines changed: 792 additions & 259 deletions

File tree

.github/workflows/test.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [main, master]
6+
pull_request:
7+
branches: [main, master]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout code
15+
uses: actions/checkout@v4
16+
17+
- name: Setup Bun
18+
uses: oven-sh/setup-bun@v2
19+
with:
20+
bun-version: latest
21+
22+
- name: Install dependencies
23+
run: bun install
24+
25+
- name: Run tests
26+
run: bun test

src/app/api/merge/route.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ describe("GET /api/merge - Multiple feeds with one returning 403", () => {
9393
});
9494

9595
try {
96+
// Suppress console.error output for expected 403 error
97+
const originalError = console.error;
98+
console.error = () => {};
99+
96100
const baseUrl = new URL("http://localhost:3000/api/merge");
97101
baseUrl.searchParams.append("url", "http://localhost:9999/feed1.xml");
98102
baseUrl.searchParams.append("url", "http://localhost:9999/feed2.xml");
@@ -102,6 +106,9 @@ describe("GET /api/merge - Multiple feeds with one returning 403", () => {
102106
const response = await GET(request);
103107
const text = await response.text();
104108

109+
// Restore console.error
110+
console.error = originalError;
111+
105112
// Verify response is valid RSS
106113
expect(response.status).toBe(200);
107114
expect(response.headers.get("content-type")).toContain(

src/app/api/merge/route.ts

Lines changed: 12 additions & 259 deletions
Original file line numberDiff line numberDiff line change
@@ -1,109 +1,13 @@
11
import LZString from "lz-string";
2-
import { encodeContent } from "@/lib/encoding";
32
import { 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

1610
const 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, "&amp;")
75-
.replace(/</g, "&lt;")
76-
.replace(/>/g, "&gt;")
77-
.replace(/"/g, "&quot;")
78-
.replace(/'/g, "&apos;");
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

10812
const 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

Comments
 (0)