Skip to content

Commit 1049d3e

Browse files
ascorbicclaude
andauthored
feat: add experimental live feed loader (#88)
* feat: add experimental live feed loader for runtime RSS/Atom feed loading Adds a new `liveFeedLoader` for Astro's experimental live content collections feature. This allows RSS/Atom feeds to be fetched at request time rather than build time, enabling real-time content updates without rebuilds. Features: - Runtime feed loading with `liveFeedLoader()` - Support for RSS, Atom, and RDF feeds - Collection filtering (limit, category, author, date ranges) - Individual entry loading by ID or URL - Structured error handling with `FeedLoadError` and `FeedValidationError` - TypeScript support with proper generics - Comprehensive test coverage (18 test cases) Demo: - Added live news collection using BBC Science & Environment RSS feed - Created news listing and individual article pages - Demonstrates proper error handling and server-side rendering Requirements: - Astro 5.10.0 or later - Experimental live content collections enabled in astro.config.mjs 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Update demo * Update changeset * Remove doc * Update demo and readme * Update --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d93b0db commit 1049d3e

22 files changed

Lines changed: 2429 additions & 417 deletions

.changeset/strange-rabbits-fail.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@ascorbic/feed-loader": minor
3+
---
4+
5+
Adds experimental live feed loader
6+
7+
Adds a new `liveFeedLoader` for Astro's experimental live content collections feature. This allows RSS/Atom feeds to be fetched at request time rather than build time, enabling real-time content updates without rebuilds.
8+
9+
**Features:**
10+
11+
- Runtime feed loading with `liveFeedLoader()`
12+
- Support for RSS, Atom, and RDF feeds
13+
- Collection filtering (limit, category, author, date ranges)
14+
- Individual entry loading by ID or URL
15+
- Structured error handling with `FeedLoadError` and `FeedValidationError`
16+
- TypeScript support with proper generics
17+
18+
**Requirements:**
19+
20+
- Astro 5.10.0 or later
21+
- Experimental live content collections enabled in `astro.config.mjs`
22+
23+
**Usage:**
24+
25+
```typescript
26+
// src/live.config.ts
27+
import { defineLiveCollection } from "astro:content";
28+
import { liveFeedLoader } from "@ascorbic/feed-loader";
29+
30+
const news = defineLiveCollection({
31+
type: "live",
32+
loader: liveFeedLoader({
33+
url: "https://feeds.example.com/news.xml",
34+
}),
35+
});
36+
```
37+
38+
The existing `feedLoader` remains unchanged and fully compatible.

demos/loaders/astro.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import netlify from "@astrojs/netlify";
66
export default defineConfig({
77
output: "static",
88
adapter: netlify(),
9+
experimental: {
10+
liveContentCollections: true,
11+
},
912
image: {
1013
domains: ["image.simplecastcdn.com"],
1114
},

demos/loaders/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"@astrojs/netlify": "^6.1.0",
2222
"@atproto/api": "^0.13.31",
2323
"@unpic/astro": "^1.0.0",
24-
"astro": "5.2.1",
24+
"astro": "5.10.0",
2525
"typescript": "^5.7.3"
2626
}
2727
}

demos/loaders/src/live.config.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineLiveCollection } from 'astro:content';
2+
import { liveFeedLoader } from '@ascorbic/feed-loader';
3+
4+
const news = defineLiveCollection({
5+
type: 'live',
6+
loader: liveFeedLoader({
7+
url: 'https://feeds.bbci.co.uk/news/science_and_environment/rss.xml',
8+
}),
9+
});
10+
11+
export const collections = { news };

demos/loaders/src/pages/index.astro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ const formatter = new Intl.DateTimeFormat("en-CA", {
1616
---
1717

1818
<Layout title="Releases">
19+
<h2>Live News Feed</h2>
20+
<p><a href="/news">BBC Science & Environment News (Live Feed Demo)</a></p>
21+
1922
<h2>Spacecraft</h2>
2023
{
2124
spacecraft.map(({ data }) => (
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
---
2+
import { getLiveEntry, render } from 'astro:content';
3+
import Layout from '../../layouts/Layout.astro';
4+
5+
export const prerender = false;
6+
7+
const { id } = Astro.params;
8+
9+
if (!id) {
10+
return Astro.redirect('/news');
11+
}
12+
13+
const decodedId = decodeURIComponent(id);
14+
const { entry: article, error } = await getLiveEntry('news', decodedId);
15+
16+
if (error) {
17+
console.error('Failed to load article:', error.message);
18+
return Astro.redirect('/news');
19+
}
20+
21+
if (!article) {
22+
return Astro.redirect('/news');
23+
}
24+
25+
const { Content } = await render(article);
26+
27+
const publishedDate = article.data.published
28+
? new Date(article.data.published).toLocaleDateString('en-US', {
29+
year: 'numeric',
30+
month: 'long',
31+
day: 'numeric'
32+
})
33+
: null;
34+
---
35+
36+
<Layout title={article.data.title || 'BBC News Article'}>
37+
<main>
38+
<nav class="breadcrumb">
39+
<a href="/news">← Back to News</a>
40+
</nav>
41+
42+
<article>
43+
<header>
44+
<h1>{article.data.title}</h1>
45+
{publishedDate && (
46+
<time class="published-date" datetime={article.data.published?.toISOString()}>
47+
{publishedDate}
48+
</time>
49+
)}
50+
{article.data.authors && article.data.authors.length > 0 && (
51+
<div class="authors">
52+
By: {article.data.authors.map(author => author.name).filter(Boolean).join(', ')}
53+
</div>
54+
)}
55+
</header>
56+
57+
{article.data.image && (
58+
<div class="article-image">
59+
<img
60+
src={article.data.image.url}
61+
alt={article.data.image.title || article.data.title || ''}
62+
/>
63+
</div>
64+
)}
65+
66+
<div class="content">
67+
<div class="description">
68+
<Content />
69+
</div>
70+
71+
{article.data.url && (
72+
<div class="read-more">
73+
<a href={article.data.url} target="_blank" rel="noopener noreferrer">
74+
Read full article on BBC →
75+
</a>
76+
</div>
77+
)}
78+
79+
{article.data.categories && article.data.categories.length > 0 && (
80+
<div class="categories">
81+
<h3>Categories:</h3>
82+
<ul>
83+
{article.data.categories.map(category => (
84+
<li class="category-tag">{category.label}</li>
85+
))}
86+
</ul>
87+
</div>
88+
)}
89+
</div>
90+
</article>
91+
</main>
92+
</Layout>
93+
94+
<style>
95+
main {
96+
margin: auto;
97+
padding: 1rem;
98+
width: 800px;
99+
max-width: calc(100% - 2rem);
100+
color: #1f2937;
101+
line-height: 1.6;
102+
background: white;
103+
border-radius: 12px;
104+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
105+
}
106+
107+
.breadcrumb {
108+
margin-bottom: 2rem;
109+
}
110+
111+
.breadcrumb a {
112+
color: #1d4ed8;
113+
text-decoration: none;
114+
font-weight: 500;
115+
}
116+
117+
.breadcrumb a:hover {
118+
text-decoration: underline;
119+
}
120+
121+
article {
122+
background: #f9fafb;
123+
border-radius: 12px;
124+
padding: 2rem;
125+
border: 1px solid #e5e7eb;
126+
}
127+
128+
header {
129+
margin-bottom: 2rem;
130+
border-bottom: 1px solid #e5e7eb;
131+
padding-bottom: 1rem;
132+
}
133+
134+
h1 {
135+
font-size: 2.5rem;
136+
font-weight: 700;
137+
line-height: 1.2;
138+
margin-bottom: 1rem;
139+
color: #1f2937;
140+
}
141+
142+
.published-date {
143+
display: block;
144+
color: #6b7280;
145+
font-size: 1rem;
146+
margin-bottom: 0.5rem;
147+
}
148+
149+
.authors {
150+
color: #6b7280;
151+
font-style: italic;
152+
}
153+
154+
.content {
155+
font-size: 1.1rem;
156+
}
157+
158+
.article-image {
159+
margin-bottom: 1.5rem;
160+
}
161+
162+
.article-image img {
163+
width: 240px;
164+
height: auto;
165+
border-radius: 8px;
166+
border: 1px solid #e5e7eb;
167+
}
168+
169+
.description {
170+
margin-bottom: 2rem;
171+
line-height: 1.7;
172+
color: #374151;
173+
}
174+
175+
.description :global(p) {
176+
color: #374151;
177+
margin-bottom: 1rem;
178+
}
179+
180+
.description :global(a) {
181+
color: #1d4ed8;
182+
}
183+
184+
.description :global(a:hover) {
185+
color: #1e40af;
186+
}
187+
188+
.read-more {
189+
margin: 2rem 0;
190+
padding: 1rem;
191+
background: #dbeafe;
192+
border-radius: 8px;
193+
border-left: 4px solid #1d4ed8;
194+
}
195+
196+
.read-more a {
197+
color: #1d4ed8;
198+
text-decoration: none;
199+
font-weight: 600;
200+
}
201+
202+
.read-more a:hover {
203+
text-decoration: underline;
204+
}
205+
206+
.categories {
207+
margin-top: 2rem;
208+
padding-top: 1rem;
209+
border-top: 1px solid #e5e7eb;
210+
}
211+
212+
.categories h3 {
213+
margin-bottom: 0.5rem;
214+
color: #6b7280;
215+
font-size: 1rem;
216+
}
217+
218+
.categories ul {
219+
list-style: none;
220+
padding: 0;
221+
display: flex;
222+
flex-wrap: wrap;
223+
gap: 0.5rem;
224+
}
225+
226+
.category-tag {
227+
background: #dbeafe;
228+
color: #1e40af;
229+
padding: 0.25rem 0.75rem;
230+
border-radius: 16px;
231+
font-size: 0.875rem;
232+
font-weight: 500;
233+
}
234+
</style>

0 commit comments

Comments
 (0)