diff --git a/app/Http/Controllers/ShowBlogController.php b/app/Http/Controllers/ShowBlogController.php
index 14a38d36..8e9a1e74 100644
--- a/app/Http/Controllers/ShowBlogController.php
+++ b/app/Http/Controllers/ShowBlogController.php
@@ -3,7 +3,9 @@
namespace App\Http\Controllers;
use App\Models\Article;
+use App\Support\BlogFeed;
use Artesaos\SEOTools\Facades\SEOTools;
+use Illuminate\Http\Response;
class ShowBlogController extends Controller
{
@@ -18,6 +20,19 @@ public function index()
]);
}
+ public function feed(BlogFeed $feed): Response
+ {
+ $articles = Article::query()
+ ->published()
+ ->with('author')
+ ->limit(20)
+ ->get();
+
+ return response($feed->toRss($articles), 200, [
+ 'Content-Type' => 'application/rss+xml; charset=UTF-8',
+ ]);
+ }
+
public function show(Article $article)
{
abort_unless($article->isPublished() || $article->isScheduled() || auth()->user()?->isAdmin(), 404);
diff --git a/app/Support/BlogFeed.php b/app/Support/BlogFeed.php
new file mode 100644
index 00000000..22c24964
--- /dev/null
+++ b/app/Support/BlogFeed.php
@@ -0,0 +1,95 @@
+ $articles
+ */
+ public function toRss(Collection $articles): string
+ {
+ $document = new DOMDocument('1.0', 'UTF-8');
+ $document->formatOutput = true;
+
+ $rss = $document->createElement('rss');
+ $rss->setAttribute('version', '2.0');
+ $rss->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:atom', self::ATOM_NAMESPACE);
+ $rss->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:dc', self::DUBLIN_CORE_NAMESPACE);
+ $rss->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:media', self::MEDIA_NAMESPACE);
+ $document->appendChild($rss);
+
+ $channel = $document->createElement('channel');
+ $rss->appendChild($channel);
+
+ $this->appendText($document, $channel, 'title', 'NativePHP Blog');
+ $this->appendText($document, $channel, 'link', route('blog'));
+
+ $self = $document->createElementNS(self::ATOM_NAMESPACE, 'atom:link');
+ $self->setAttribute('href', route('blog.feed'));
+ $self->setAttribute('rel', 'self');
+ $self->setAttribute('type', 'application/rss+xml');
+ $channel->appendChild($self);
+
+ $this->appendText($document, $channel, 'description', 'Insights, updates, and stories from the NativePHP community.');
+ $this->appendText($document, $channel, 'language', 'en');
+
+ if ($articles->isNotEmpty()) {
+ $this->appendText($document, $channel, 'lastBuildDate', $articles->max('updated_at')->toRssString());
+ }
+
+ foreach ($articles as $article) {
+ $this->appendItem($document, $channel, $article);
+ }
+
+ return (string) $document->saveXML();
+ }
+
+ private function appendItem(DOMDocument $document, DOMElement $channel, Article $article): void
+ {
+ $item = $document->createElement('item');
+ $channel->appendChild($item);
+
+ $url = route('article', $article);
+
+ $this->appendText($document, $item, 'title', $article->title);
+ $this->appendText($document, $item, 'link', $url);
+ $this->appendText($document, $item, 'guid', $url)->setAttribute('isPermaLink', 'true');
+ $this->appendText($document, $item, 'pubDate', $article->published_at->toRssString());
+
+ if ($article->author?->name) {
+ $creator = $document->createElementNS(self::DUBLIN_CORE_NAMESPACE, 'dc:creator');
+ $creator->appendChild($document->createTextNode($article->author->name));
+ $item->appendChild($creator);
+ }
+
+ $this->appendText($document, $item, 'description', $article->excerpt ?? '');
+
+ if ($article->og_image) {
+ $media = $document->createElementNS(self::MEDIA_NAMESPACE, 'media:content');
+ $media->setAttribute('url', $article->og_image);
+ $media->setAttribute('medium', 'image');
+ $item->appendChild($media);
+ }
+ }
+
+ private function appendText(DOMDocument $document, DOMElement $parent, string $name, string $value): DOMElement
+ {
+ $element = $document->createElement($name);
+ $element->appendChild($document->createTextNode($value));
+ $parent->appendChild($element);
+
+ return $element;
+ }
+}
diff --git a/resources/views/components/layout.blade.php b/resources/views/components/layout.blade.php
index f5360382..2e51a12c 100644
--- a/resources/views/components/layout.blade.php
+++ b/resources/views/components/layout.blade.php
@@ -66,6 +66,14 @@
type="image/svg+xml"
/>
+ {{-- Blog RSS feed autodiscovery --}}
+
+
{!! SEOMeta::generate() !!}
{!! OpenGraph::generate() !!}
{!! Twitter::generate() !!}
diff --git a/routes/web.php b/routes/web.php
index a1d99813..71e568a9 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -196,6 +196,7 @@
Route::view('vs-flutter', 'vs-flutter')->name('vs-flutter');
Route::get('blog', [ShowBlogController::class, 'index'])->name('blog');
+Route::get('blog/feed', [ShowBlogController::class, 'feed'])->name('blog.feed');
Route::get('blog/{article}', [ShowBlogController::class, 'show'])->name('article');
Route::get('docs/{platform}/{version}/{page}.md', [ShowDocumentationController::class, 'serveRawMarkdown'])
diff --git a/tests/Feature/BlogFeedTest.php b/tests/Feature/BlogFeedTest.php
new file mode 100644
index 00000000..857f2513
--- /dev/null
+++ b/tests/Feature/BlogFeedTest.php
@@ -0,0 +1,125 @@
+get(route('blog.feed'))
+ ->assertOk()
+ ->assertHeader('Content-Type', 'application/rss+xml; charset=UTF-8')
+ ->assertSee('assertSee('', false);
+ }
+
+ #[Test]
+ public function published_articles_appear_in_the_feed()
+ {
+ $article = Article::factory()->published()->create();
+
+ $this->get(route('blog.feed'))
+ ->assertOk()
+ ->assertSee($article->title, false)
+ ->assertSee(route('article', $article), false)
+ ->assertSee($article->excerpt, false);
+ }
+
+ #[Test]
+ public function articles_expose_their_open_graph_image_in_the_feed()
+ {
+ $article = Article::factory()->published()->create([
+ 'og_image' => 'https://example.com/og/cover.png',
+ ]);
+
+ $this->get(route('blog.feed'))
+ ->assertOk()
+ ->assertSee('assertSee('url="https://example.com/og/cover.png"', false)
+ ->assertSee('medium="image"', false);
+ }
+
+ #[Test]
+ public function articles_without_an_open_graph_image_omit_the_media_tag()
+ {
+ Article::factory()->published()->create([
+ 'og_image' => null,
+ ]);
+
+ $this->get(route('blog.feed'))
+ ->assertOk()
+ ->assertDontSee('freezeTime();
+
+ Article::factory()->published()->create([
+ 'updated_at' => now()->subWeek(),
+ ]);
+
+ $mostRecentlyUpdated = Article::factory()->published()->create([
+ 'updated_at' => now()->subDay(),
+ ]);
+
+ $this->get(route('blog.feed'))
+ ->assertOk()
+ ->assertSee(''.$mostRecentlyUpdated->updated_at->toRssString().'', false);
+ }
+
+ #[Test]
+ public function scheduled_articles_do_not_appear_in_the_feed()
+ {
+ $article = Article::factory()->scheduled()->create();
+
+ $this->get(route('blog.feed'))
+ ->assertOk()
+ ->assertDontSee($article->title, false);
+ }
+
+ #[Test]
+ public function draft_articles_do_not_appear_in_the_feed()
+ {
+ $article = Article::factory()->create([
+ 'published_at' => null,
+ ]);
+
+ $this->get(route('blog.feed'))
+ ->assertOk()
+ ->assertDontSee($article->title, false);
+ }
+
+ #[Test]
+ public function articles_appear_in_the_feed_in_reverse_chronological_order()
+ {
+ $older = Article::factory()->create([
+ 'published_at' => now()->subDays(2),
+ ]);
+
+ $newer = Article::factory()->create([
+ 'published_at' => now()->subDay(),
+ ]);
+
+ $this->get(route('blog.feed'))
+ ->assertOk()
+ ->assertSeeInOrder([$newer->title, $older->title], false);
+ }
+
+ #[Test]
+ public function the_blog_page_links_to_the_feed_for_autodiscovery()
+ {
+ $this->get(route('blog'))
+ ->assertOk()
+ ->assertSee(route('blog.feed'), false);
+ }
+}