Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/Http/Controllers/ShowBlogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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);
Expand Down
95 changes: 95 additions & 0 deletions app/Support/BlogFeed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace App\Support;

use App\Models\Article;
use DOMDocument;
use DOMElement;
use Illuminate\Support\Collection;

class BlogFeed
{
private const string ATOM_NAMESPACE = 'http://www.w3.org/2005/Atom';

private const string DUBLIN_CORE_NAMESPACE = 'http://purl.org/dc/elements/1.1/';

private const string MEDIA_NAMESPACE = 'http://search.yahoo.com/mrss/';

/**
* @param Collection<int, Article> $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;
}
}
8 changes: 8 additions & 0 deletions resources/views/components/layout.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@
type="image/svg+xml"
/>

{{-- Blog RSS feed autodiscovery --}}
<link
rel="alternate"
type="application/rss+xml"
title="NativePHP Blog"
href="{{ route('blog.feed') }}"
/>

{!! SEOMeta::generate() !!}
{!! OpenGraph::generate() !!}
{!! Twitter::generate() !!}
Expand Down
1 change: 1 addition & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down
125 changes: 125 additions & 0 deletions tests/Feature/BlogFeedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

namespace Tests\Feature;

use App\Models\Article;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class BlogFeedTest extends TestCase
{
use RefreshDatabase;

#[Test]
public function the_blog_feed_is_served_as_rss_xml()
{
$this->get(route('blog.feed'))
->assertOk()
->assertHeader('Content-Type', 'application/rss+xml; charset=UTF-8')
->assertSee('<rss', false)
->assertSee('<channel>', 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('<media:content', false)
->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('<media:content', false);
}

#[Test]
public function the_feed_last_build_date_reflects_the_most_recently_updated_article()
{
$this->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('<lastBuildDate>'.$mostRecentlyUpdated->updated_at->toRssString().'</lastBuildDate>', 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);
}
}
Loading