Skip to content

Commit 394ef38

Browse files
simonhampclaude
andauthored
Add RSS feed for the blog (#412)
Expose the blog at /blog/feed (route blog.feed) as an RSS 2.0 document, generated with DOMDocument for correctly-escaped, cleanly-indented XML. Items reuse the Article published() scope (newest first, drafts and scheduled posts excluded) and carry the excerpt as the description plus the OpenGraph image via media:content. lastBuildDate tracks the most recent article update, and the layout advertises the feed for reader autodiscovery. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e874044 commit 394ef38

5 files changed

Lines changed: 244 additions & 0 deletions

File tree

app/Http/Controllers/ShowBlogController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
namespace App\Http\Controllers;
44

55
use App\Models\Article;
6+
use App\Support\BlogFeed;
67
use Artesaos\SEOTools\Facades\SEOTools;
8+
use Illuminate\Http\Response;
79

810
class ShowBlogController extends Controller
911
{
@@ -18,6 +20,19 @@ public function index()
1820
]);
1921
}
2022

23+
public function feed(BlogFeed $feed): Response
24+
{
25+
$articles = Article::query()
26+
->published()
27+
->with('author')
28+
->limit(20)
29+
->get();
30+
31+
return response($feed->toRss($articles), 200, [
32+
'Content-Type' => 'application/rss+xml; charset=UTF-8',
33+
]);
34+
}
35+
2136
public function show(Article $article)
2237
{
2338
abort_unless($article->isPublished() || $article->isScheduled() || auth()->user()?->isAdmin(), 404);

app/Support/BlogFeed.php

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
namespace App\Support;
4+
5+
use App\Models\Article;
6+
use DOMDocument;
7+
use DOMElement;
8+
use Illuminate\Support\Collection;
9+
10+
class BlogFeed
11+
{
12+
private const string ATOM_NAMESPACE = 'http://www.w3.org/2005/Atom';
13+
14+
private const string DUBLIN_CORE_NAMESPACE = 'http://purl.org/dc/elements/1.1/';
15+
16+
private const string MEDIA_NAMESPACE = 'http://search.yahoo.com/mrss/';
17+
18+
/**
19+
* @param Collection<int, Article> $articles
20+
*/
21+
public function toRss(Collection $articles): string
22+
{
23+
$document = new DOMDocument('1.0', 'UTF-8');
24+
$document->formatOutput = true;
25+
26+
$rss = $document->createElement('rss');
27+
$rss->setAttribute('version', '2.0');
28+
$rss->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:atom', self::ATOM_NAMESPACE);
29+
$rss->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:dc', self::DUBLIN_CORE_NAMESPACE);
30+
$rss->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:media', self::MEDIA_NAMESPACE);
31+
$document->appendChild($rss);
32+
33+
$channel = $document->createElement('channel');
34+
$rss->appendChild($channel);
35+
36+
$this->appendText($document, $channel, 'title', 'NativePHP Blog');
37+
$this->appendText($document, $channel, 'link', route('blog'));
38+
39+
$self = $document->createElementNS(self::ATOM_NAMESPACE, 'atom:link');
40+
$self->setAttribute('href', route('blog.feed'));
41+
$self->setAttribute('rel', 'self');
42+
$self->setAttribute('type', 'application/rss+xml');
43+
$channel->appendChild($self);
44+
45+
$this->appendText($document, $channel, 'description', 'Insights, updates, and stories from the NativePHP community.');
46+
$this->appendText($document, $channel, 'language', 'en');
47+
48+
if ($articles->isNotEmpty()) {
49+
$this->appendText($document, $channel, 'lastBuildDate', $articles->max('updated_at')->toRssString());
50+
}
51+
52+
foreach ($articles as $article) {
53+
$this->appendItem($document, $channel, $article);
54+
}
55+
56+
return (string) $document->saveXML();
57+
}
58+
59+
private function appendItem(DOMDocument $document, DOMElement $channel, Article $article): void
60+
{
61+
$item = $document->createElement('item');
62+
$channel->appendChild($item);
63+
64+
$url = route('article', $article);
65+
66+
$this->appendText($document, $item, 'title', $article->title);
67+
$this->appendText($document, $item, 'link', $url);
68+
$this->appendText($document, $item, 'guid', $url)->setAttribute('isPermaLink', 'true');
69+
$this->appendText($document, $item, 'pubDate', $article->published_at->toRssString());
70+
71+
if ($article->author?->name) {
72+
$creator = $document->createElementNS(self::DUBLIN_CORE_NAMESPACE, 'dc:creator');
73+
$creator->appendChild($document->createTextNode($article->author->name));
74+
$item->appendChild($creator);
75+
}
76+
77+
$this->appendText($document, $item, 'description', $article->excerpt ?? '');
78+
79+
if ($article->og_image) {
80+
$media = $document->createElementNS(self::MEDIA_NAMESPACE, 'media:content');
81+
$media->setAttribute('url', $article->og_image);
82+
$media->setAttribute('medium', 'image');
83+
$item->appendChild($media);
84+
}
85+
}
86+
87+
private function appendText(DOMDocument $document, DOMElement $parent, string $name, string $value): DOMElement
88+
{
89+
$element = $document->createElement($name);
90+
$element->appendChild($document->createTextNode($value));
91+
$parent->appendChild($element);
92+
93+
return $element;
94+
}
95+
}

resources/views/components/layout.blade.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@
6666
type="image/svg+xml"
6767
/>
6868

69+
{{-- Blog RSS feed autodiscovery --}}
70+
<link
71+
rel="alternate"
72+
type="application/rss+xml"
73+
title="NativePHP Blog"
74+
href="{{ route('blog.feed') }}"
75+
/>
76+
6977
{!! SEOMeta::generate() !!}
7078
{!! OpenGraph::generate() !!}
7179
{!! Twitter::generate() !!}

routes/web.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@
196196
Route::view('vs-flutter', 'vs-flutter')->name('vs-flutter');
197197

198198
Route::get('blog', [ShowBlogController::class, 'index'])->name('blog');
199+
Route::get('blog/feed', [ShowBlogController::class, 'feed'])->name('blog.feed');
199200
Route::get('blog/{article}', [ShowBlogController::class, 'show'])->name('article');
200201

201202
Route::get('docs/{platform}/{version}/{page}.md', [ShowDocumentationController::class, 'serveRawMarkdown'])

tests/Feature/BlogFeedTest.php

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Models\Article;
6+
use Illuminate\Foundation\Testing\RefreshDatabase;
7+
use PHPUnit\Framework\Attributes\Test;
8+
use Tests\TestCase;
9+
10+
class BlogFeedTest extends TestCase
11+
{
12+
use RefreshDatabase;
13+
14+
#[Test]
15+
public function the_blog_feed_is_served_as_rss_xml()
16+
{
17+
$this->get(route('blog.feed'))
18+
->assertOk()
19+
->assertHeader('Content-Type', 'application/rss+xml; charset=UTF-8')
20+
->assertSee('<rss', false)
21+
->assertSee('<channel>', false);
22+
}
23+
24+
#[Test]
25+
public function published_articles_appear_in_the_feed()
26+
{
27+
$article = Article::factory()->published()->create();
28+
29+
$this->get(route('blog.feed'))
30+
->assertOk()
31+
->assertSee($article->title, false)
32+
->assertSee(route('article', $article), false)
33+
->assertSee($article->excerpt, false);
34+
}
35+
36+
#[Test]
37+
public function articles_expose_their_open_graph_image_in_the_feed()
38+
{
39+
$article = Article::factory()->published()->create([
40+
'og_image' => 'https://example.com/og/cover.png',
41+
]);
42+
43+
$this->get(route('blog.feed'))
44+
->assertOk()
45+
->assertSee('<media:content', false)
46+
->assertSee('url="https://example.com/og/cover.png"', false)
47+
->assertSee('medium="image"', false);
48+
}
49+
50+
#[Test]
51+
public function articles_without_an_open_graph_image_omit_the_media_tag()
52+
{
53+
Article::factory()->published()->create([
54+
'og_image' => null,
55+
]);
56+
57+
$this->get(route('blog.feed'))
58+
->assertOk()
59+
->assertDontSee('<media:content', false);
60+
}
61+
62+
#[Test]
63+
public function the_feed_last_build_date_reflects_the_most_recently_updated_article()
64+
{
65+
$this->freezeTime();
66+
67+
Article::factory()->published()->create([
68+
'updated_at' => now()->subWeek(),
69+
]);
70+
71+
$mostRecentlyUpdated = Article::factory()->published()->create([
72+
'updated_at' => now()->subDay(),
73+
]);
74+
75+
$this->get(route('blog.feed'))
76+
->assertOk()
77+
->assertSee('<lastBuildDate>'.$mostRecentlyUpdated->updated_at->toRssString().'</lastBuildDate>', false);
78+
}
79+
80+
#[Test]
81+
public function scheduled_articles_do_not_appear_in_the_feed()
82+
{
83+
$article = Article::factory()->scheduled()->create();
84+
85+
$this->get(route('blog.feed'))
86+
->assertOk()
87+
->assertDontSee($article->title, false);
88+
}
89+
90+
#[Test]
91+
public function draft_articles_do_not_appear_in_the_feed()
92+
{
93+
$article = Article::factory()->create([
94+
'published_at' => null,
95+
]);
96+
97+
$this->get(route('blog.feed'))
98+
->assertOk()
99+
->assertDontSee($article->title, false);
100+
}
101+
102+
#[Test]
103+
public function articles_appear_in_the_feed_in_reverse_chronological_order()
104+
{
105+
$older = Article::factory()->create([
106+
'published_at' => now()->subDays(2),
107+
]);
108+
109+
$newer = Article::factory()->create([
110+
'published_at' => now()->subDay(),
111+
]);
112+
113+
$this->get(route('blog.feed'))
114+
->assertOk()
115+
->assertSeeInOrder([$newer->title, $older->title], false);
116+
}
117+
118+
#[Test]
119+
public function the_blog_page_links_to_the_feed_for_autodiscovery()
120+
{
121+
$this->get(route('blog'))
122+
->assertOk()
123+
->assertSee(route('blog.feed'), false);
124+
}
125+
}

0 commit comments

Comments
 (0)