Skip to content

Commit 2f0919e

Browse files
committed
typescript via copilot for the rss parsing win
1 parent c7bb113 commit 2f0919e

File tree

5 files changed

+258
-98
lines changed

5 files changed

+258
-98
lines changed

app/page.tsx

Lines changed: 72 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,77 @@
1-
import Image from "next/image";
21

3-
export default function Home() {
4-
return (
5-
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
6-
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
7-
<Image
8-
className="dark:invert"
9-
src="/bookmarks/next.svg"
10-
alt="Next.js logo"
11-
width={180}
12-
height={38}
13-
priority
14-
/>
15-
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
16-
<li className="mb-2">
17-
Get started by editing{" "}
18-
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
19-
app/page.tsx
20-
</code>
21-
.
22-
</li>
23-
<li>Save and see your changes instantly.</li>
24-
</ol>
2+
import { GetStaticProps, InferGetStaticPropsType } from 'next';
3+
import { RSSFeed } from '../types/rss';
4+
import { parseRSSFeed } from '../lib/rssParser';
5+
6+
interface BlogPageProps {
7+
feed: RSSFeed;
8+
lastUpdated: string;
9+
}
2510

26-
<div className="flex gap-4 items-center flex-col sm:flex-row">
27-
<a
28-
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
29-
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
30-
target="_blank"
31-
rel="noopener noreferrer"
32-
>
33-
<Image
34-
className="dark:invert"
35-
src="/nextjs-github-pages/vercel.svg"
36-
alt="Vercel logomark"
37-
width={20}
38-
height={20}
11+
export default async function Page() {
12+
let myBlog: BlogPageProps;
13+
try {
14+
// Replace with your RSS feed URL
15+
const RSS_FEED_URL = 'https://bg.raindrop.io/rss/public/58719856';
16+
const feed = await parseRSSFeed(RSS_FEED_URL);
17+
myBlog = {
18+
feed,
19+
lastUpdated: new Date().toISOString(),
20+
};
21+
} catch (error) {
22+
console.error('Error fetching RSS feed:', error);
23+
// Return empty feed on error to prevent build failures
24+
myBlog = {
25+
feed: {
26+
title: 'Blog Feed',
27+
description: 'Unable to load feed',
28+
link: '',
29+
items: [],
30+
},
31+
lastUpdated: new Date().toISOString(),
32+
};
33+
}
34+
return (
35+
<div className="container mx-auto px-4 py-8">
36+
<h1 className="text-3xl font-bold mb-6">{myBlog.feed.title}</h1>
37+
<p className="text-gray-600 mb-8">{myBlog.feed.description}</p>
38+
<p className="text-sm text-gray-500 mb-8">Last updated: {myBlog.lastUpdated}</p>
39+
<div className="space-y-6">
40+
{myBlog.feed.items.map((item, index) => (
41+
<article key={item.guid || index} className="border-b pb-6">
42+
<h2 className="text-xl font-semibold mb-2">
43+
<a
44+
href={item.link}
45+
target="_blank"
46+
rel="noopener noreferrer"
47+
className="text-blue-600 hover:text-blue-800"
48+
>
49+
{item.title}
50+
</a>
51+
</h2>
52+
<div
53+
className="text-gray-700 mb-2"
54+
dangerouslySetInnerHTML={{ __html: item.description }}
3955
/>
40-
Deploy now
41-
</a>
42-
<a
43-
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
44-
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
45-
target="_blank"
46-
rel="noopener noreferrer"
47-
>
48-
Read our docs
49-
</a>
50-
</div>
51-
</main>
52-
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
53-
<a
54-
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
55-
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
56-
target="_blank"
57-
rel="noopener noreferrer"
58-
>
59-
<Image
60-
aria-hidden
61-
src="/nextjs-github-pages/file.svg"
62-
alt="File icon"
63-
width={16}
64-
height={16}
65-
/>
66-
Learn
67-
</a>
68-
<a
69-
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
70-
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
71-
target="_blank"
72-
rel="noopener noreferrer"
73-
>
74-
<Image
75-
aria-hidden
76-
src="/nextjs-github-pages/window.svg"
77-
alt="Window icon"
78-
width={16}
79-
height={16}
80-
/>
81-
Examples
82-
</a>
83-
<a
84-
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
85-
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
86-
target="_blank"
87-
rel="noopener noreferrer"
88-
>
89-
<Image
90-
aria-hidden
91-
src="/nextjs-github-pages/globe.svg"
92-
alt="Globe icon"
93-
width={16}
94-
height={16}
95-
/>
96-
Go to nextjs.org →
97-
</a>
98-
</footer>
56+
<div className="text-sm text-gray-500">
57+
{item.pubDate && <span>Published: {new Date(item.pubDate).toLocaleDateString()}</span>}
58+
{item.author && <span className="ml-4">By: {item.author}</span>}
59+
</div>
60+
{item.categories && item.categories.length > 0 && (
61+
<div className="mt-2">
62+
{item.categories.map(category => (
63+
<span
64+
key={category}
65+
className="inline-block bg-gray-200 rounded-full px-3 py-1 text-sm font-semibold text-gray-700 mr-2"
66+
>
67+
{category}
68+
</span>
69+
))}
70+
</div>
71+
)}
72+
</article>
73+
))}
74+
</div>
9975
</div>
10076
);
101-
}
77+
}

lib/rssParser.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
2+
// lib/rssParser.ts
3+
import { parseString } from 'xml2js';
4+
import { RSSFeed, RSSItem } from '../types/rss';
5+
6+
export async function parseRSSFeed(url: string): Promise<RSSFeed> {
7+
try {
8+
// Fetch the RSS feed
9+
const response = await fetch(url);
10+
if (!response.ok) {
11+
throw new Error(`HTTP error! status: ${response.status}`);
12+
}
13+
14+
const xmlData = await response.text();
15+
16+
// Parse XML to JSON
17+
return new Promise((resolve, reject) => {
18+
parseString(xmlData, { explicitArray: false }, (err, result) => {
19+
if (err) {
20+
reject(new Error(`XML parsing error: ${err.message}`));
21+
return;
22+
}
23+
24+
try {
25+
const channel = result.rss?.channel;
26+
if (!channel) {
27+
throw new Error('Invalid RSS format: missing channel');
28+
}
29+
30+
// Handle both single item and array of items
31+
const rawItems = Array.isArray(channel.item) ? channel.item : [channel.item].filter(Boolean);
32+
33+
const items: RSSItem[] = rawItems.map((item: any) => ({
34+
title: item.title || '',
35+
description: item.description || '',
36+
link: item.link || '',
37+
pubDate: item.pubDate || '',
38+
guid: item.guid?._?.toString() || item.guid?.toString(),
39+
author: item.author || item['dc:creator'],
40+
categories: Array.isArray(item.category)
41+
? item.category
42+
: item.category
43+
? [item.category]
44+
: undefined,
45+
}));
46+
47+
const feed: RSSFeed = {
48+
title: channel.title || '',
49+
description: channel.description || '',
50+
link: channel.link || '',
51+
items,
52+
};
53+
54+
resolve(feed);
55+
} catch (parseErr) {
56+
reject(new Error(`RSS structure parsing error: ${parseErr}`));
57+
}
58+
});
59+
});
60+
} catch (error) {
61+
throw new Error(`Failed to fetch RSS feed: ${error}`);
62+
}
63+
}
64+
65+
// package.json dependencies needed:
66+
/*
67+
{
68+
"dependencies": {
69+
"xml2js": "^0.4.23",
70+
"@types/xml2js": "^0.4.11"
71+
}
72+
}
73+
*/
74+
75+
// utils/rssCache.ts - Optional caching utility
76+
interface CacheEntry {
77+
data: RSSFeed;
78+
timestamp: number;
79+
ttl: number;
80+
}
81+
82+
class RSSCache {
83+
private cache = new Map<string, CacheEntry>();
84+
85+
async get(url: string, ttlMinutes: number = 30): Promise<RSSFeed | null> {
86+
const entry = this.cache.get(url);
87+
88+
if (!entry) return null;
89+
90+
const isExpired = Date.now() - entry.timestamp > entry.ttl;
91+
if (isExpired) {
92+
this.cache.delete(url);
93+
return null;
94+
}
95+
96+
return entry.data;
97+
}
98+
99+
set(url: string, data: RSSFeed, ttlMinutes: number = 30): void {
100+
this.cache.set(url, {
101+
data,
102+
timestamp: Date.now(),
103+
ttl: ttlMinutes * 60 * 1000,
104+
});
105+
}
106+
107+
clear(): void {
108+
this.cache.clear();
109+
}
110+
}
111+
112+
export const rssCache = new RSSCache();
113+
114+
// Enhanced parser with caching
115+
export async function parseRSSFeedWithCache(url: string, ttlMinutes: number = 30): Promise<RSSFeed> {
116+
// Check cache first
117+
const cached = await rssCache.get(url, ttlMinutes);
118+
if (cached) {
119+
return cached;
120+
}
121+
122+
// Parse fresh data
123+
const feed = await parseRSSFeed(url);
124+
125+
// Cache the result
126+
rssCache.set(url, feed, ttlMinutes);
127+
128+
return feed;
129+
}

package-lock.json

Lines changed: 37 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@
1111
"dependencies": {
1212
"next": "^15.4.6",
1313
"react": "^19.1.1",
14-
"react-dom": "^19.1.1"
14+
"react-dom": "^19.1.1",
15+
"xml2js": "^0.6.2"
1516
},
1617
"devDependencies": {
1718
"@eslint/eslintrc": "^3",
1819
"@tailwindcss/postcss": "^4.1.11",
1920
"@types/node": "^24",
2021
"@types/react": "^19",
2122
"@types/react-dom": "^19",
23+
"@types/xml2js": "^0.4.14",
2224
"eslint": "^9",
2325
"eslint-config-next": "^15",
2426
"postcss": "^8",

0 commit comments

Comments
 (0)