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); + } +}